Merge remote-tracking branch 'upstream/develop' into webpack-4-dart-sass

* upstream/develop: (126 commits)
  entity normalizer: hook up in_reply_to_account_acct
  add BBCode strings
  fix follow button not updating bug in follow-card
  refer searched user objects from the global user rep
  set max-width of textarea in settings page
  Remove space in the timeline setting copy
  user_card.vue: Fix .emoji to apply to img
  Update oc.json
  Update oc.json
  Update oc.json
  Update oc.json
  replace pencil with wrench icon
  rebuild fontello with wrench icon added
  fetch all friends using pagination
  stop fetching user relationship when user is unauthorized
  Revert "recover border between basic-user-card using list component"
  remove extra spacing
  code readability
  fix typos
  clean up
  ...
This commit is contained in:
Henry Jameson 2019-04-27 12:26:17 +03:00
commit e0247e21f6
76 changed files with 1859 additions and 513 deletions

View File

@ -24,11 +24,13 @@
"localforage": "^1.5.0",
"object-path": "^0.11.3",
"phoenix": "^1.3.0",
"popper.js": "^1.14.7",
"sanitize-html": "^1.13.0",
"v-click-outside": "^2.1.1",
"vue": "^2.5.13",
"vue-chat-scroll": "^1.2.1",
"vue-compose": "^0.7.1",
"vue-i18n": "^7.3.2",
"vue-popperjs": "^2.0.3",
"vue-router": "^3.0.1",
"vue-template-compiler": "^2.3.4",
"vue-timeago": "^3.1.2",

View File

@ -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 {

View File

@ -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)
}

View File

@ -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
}
}
}

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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 {

View File

@ -13,7 +13,7 @@
:key="status.id"
:inlineExpanded="collapsable"
:statusoid="status"
:expandable='!expanded'
:expandable='!isExpanded'
:focused="focused(status.id)"
:inConversation="isExpanded"
:highlight="getHighlight()"

View File

@ -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
}
}
}

View File

@ -0,0 +1,14 @@
const DialogModal = {
props: {
darkOverlay: {
default: true,
type: Boolean
},
onCancel: {
default: () => {},
type: Function
}
}
}
export default DialogModal

View File

@ -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>

View File

@ -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
})
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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)
}
})
})
},

View File

@ -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

View File

@ -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>

View File

@ -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
}
}
}

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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;
}

View File

@ -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>

View File

@ -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')

View File

@ -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')

View File

@ -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

View File

@ -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>

View File

@ -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;
}

View File

@ -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 () {

View File

@ -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,

View File

@ -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 () {

View File

@ -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;
}

View File

@ -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
}
}

View File

@ -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')"

View File

@ -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')
})
}
}

View File

@ -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>

View File

@ -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
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -1,6 +0,0 @@
.with-list {
&-empty-content {
text-align: center;
padding: 10px;
}
}

View File

@ -1,7 +1,13 @@
@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;

View File

@ -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.",

View File

@ -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",

View File

@ -20,7 +20,10 @@
"submit": "Mandar",
"more": "Mai",
"generic_error": "Una error ses producha",
"optional": "opcional"
"optional": "opcional",
"show_more": "Mostrar mai",
"show_less": "Mostrar mens",
"cancel": "Anullar"
},
"image_cropper": {
"crop_picture": "Talhar limatge",
@ -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 laplicacion",
"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 dopcions 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 dutilizacion",
"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 dutilizaire dinstàncias alonhadas",
"disable_any_subscription": "Desactivar tot seguiment",
"quarantine": "Defendre la federacion de las publicacions de lutilizaire",
"delete_user": "Suprimir lutilizaire",
"delete_user_confirmation": "Volètz vertadièrament far aquò? Aquesta accion se pòt pas anullar."
}
},
"user_profile": {
"timeline_title": "Flux utilizaire",

View File

@ -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",

View File

@ -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": "Лента пользователя"

View File

@ -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,

View File

@ -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)

View File

@ -48,7 +48,6 @@ const interfaceMod = {
commit('setNotificationPermission', permission)
},
setMobileLayout ({ commit }, value) {
console.log('setMobileLayout called')
commit('setMobileLayout', value)
}
}

View File

@ -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 })

View File

@ -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)))
},
addFollowers ({ rootState, commit }, fetchBy) {
const user = rootState.users.usersObject[fetchBy]
const maxId = user.lastFollowerId
return rootState.api.backendInteractor.fetchFollowers({ id: user.id, maxId })
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
})
},
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')

View File

@ -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,

View File

@ -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,

View File

@ -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)

View File

@ -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 })
}
})
})

View File

@ -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
}
}

View File

@ -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 })
}
return result
}
}
args['timeline'] = 'notifications'
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 }))

View File

@ -245,6 +245,12 @@
"css": "bell-alt",
"code": 61683,
"src": "fontawesome"
},
{
"uid": "5bb103cd29de77e0e06a52638527b575",
"css": "wrench",
"code": 59418,
"src": "fontawesome"
}
]
}

View File

@ -24,6 +24,8 @@
.icon-adjust:before { content: '\e816'; } /* '' */
.icon-edit:before { content: '\e817'; } /* '' */
.icon-pencil:before { content: '\e818'; } /* '' */
.icon-verified:before { content: '\e819'; } /* '' */
.icon-wrench:before { content: '\e81a'; } /* '' */
.icon-spin3:before { content: '\e832'; } /* '' */
.icon-spin4:before { content: '\e834'; } /* '' */
.icon-link-ext:before { content: '\f08e'; } /* '' */

File diff suppressed because one or more lines are too long

View File

@ -24,6 +24,8 @@
.icon-adjust { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe816;&nbsp;'); }
.icon-edit { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe817;&nbsp;'); }
.icon-pencil { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe818;&nbsp;'); }
.icon-verified { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe819;&nbsp;'); }
.icon-wrench { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe81a;&nbsp;'); }
.icon-spin3 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe832;&nbsp;'); }
.icon-spin4 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe834;&nbsp;'); }
.icon-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf08e;&nbsp;'); }

View File

@ -35,6 +35,8 @@
.icon-adjust { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe816;&nbsp;'); }
.icon-edit { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe817;&nbsp;'); }
.icon-pencil { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe818;&nbsp;'); }
.icon-verified { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe819;&nbsp;'); }
.icon-wrench { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe81a;&nbsp;'); }
.icon-spin3 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe832;&nbsp;'); }
.icon-spin4 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe834;&nbsp;'); }
.icon-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf08e;&nbsp;'); }

View File

@ -1,11 +1,11 @@
@font-face {
font-family: 'fontello';
src: url('../font/fontello.eot?72648396');
src: url('../font/fontello.eot?72648396#iefix') format('embedded-opentype'),
url('../font/fontello.woff2?72648396') format('woff2'),
url('../font/fontello.woff?72648396') format('woff'),
url('../font/fontello.ttf?72648396') format('truetype'),
url('../font/fontello.svg?72648396#fontello') format('svg');
src: url('../font/fontello.eot?11878820');
src: url('../font/fontello.eot?11878820#iefix') format('embedded-opentype'),
url('../font/fontello.woff2?11878820') format('woff2'),
url('../font/fontello.woff?11878820') format('woff'),
url('../font/fontello.ttf?11878820') format('truetype'),
url('../font/fontello.svg?11878820#fontello') format('svg');
font-weight: normal;
font-style: normal;
}
@ -15,7 +15,7 @@
@media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face {
font-family: 'fontello';
src: url('../font/fontello.svg?72648396#fontello') format('svg');
src: url('../font/fontello.svg?11878820#fontello') format('svg');
}
}
*/
@ -80,6 +80,8 @@
.icon-adjust:before { content: '\e816'; } /* '' */
.icon-edit:before { content: '\e817'; } /* '' */
.icon-pencil:before { content: '\e818'; } /* '' */
.icon-verified:before { content: '\e819'; } /* '' */
.icon-wrench:before { content: '\e81a'; } /* '' */
.icon-spin3:before { content: '\e832'; } /* '' */
.icon-spin4:before { content: '\e834'; } /* '' */
.icon-link-ext:before { content: '\f08e'; } /* '' */

26
static/font/demo.html Normal file → Executable file
View File

@ -229,11 +229,11 @@ body {
}
@font-face {
font-family: 'fontello';
src: url('./font/fontello.eot?23081587');
src: url('./font/fontello.eot?23081587#iefix') format('embedded-opentype'),
url('./font/fontello.woff?23081587') format('woff'),
url('./font/fontello.ttf?23081587') format('truetype'),
url('./font/fontello.svg?23081587#fontello') format('svg');
src: url('./font/fontello.eot?60799712');
src: url('./font/fontello.eot?60799712#iefix') format('embedded-opentype'),
url('./font/fontello.woff?60799712') format('woff'),
url('./font/fontello.ttf?60799712') format('truetype'),
url('./font/fontello.svg?60799712#fontello') format('svg');
font-weight: normal;
font-style: normal;
}
@ -335,25 +335,29 @@ body {
</div>
<div class="row">
<div class="the-icons span3" title="Code: 0xe818"><i class="demo-icon icon-pencil">&#xe818;</i> <span class="i-name">icon-pencil</span><span class="i-code">0xe818</span></div>
<div class="the-icons span3" title="Code: 0xe819"><i class="demo-icon icon-verified">&#xe819;</i> <span class="i-name">icon-verified</span><span class="i-code">0xe819</span></div>
<div class="the-icons span3" title="Code: 0xe81a"><i class="demo-icon icon-wrench">&#xe81a;</i> <span class="i-name">icon-wrench</span><span class="i-code">0xe81a</span></div>
<div class="the-icons span3" title="Code: 0xe832"><i class="demo-icon icon-spin3 animate-spin">&#xe832;</i> <span class="i-name">icon-spin3</span><span class="i-code">0xe832</span></div>
</div>
<div class="row">
<div class="the-icons span3" title="Code: 0xe834"><i class="demo-icon icon-spin4 animate-spin">&#xe834;</i> <span class="i-name">icon-spin4</span><span class="i-code">0xe834</span></div>
<div class="the-icons span3" title="Code: 0xf08e"><i class="demo-icon icon-link-ext">&#xf08e;</i> <span class="i-name">icon-link-ext</span><span class="i-code">0xf08e</span></div>
</div>
<div class="row">
<div class="the-icons span3" title="Code: 0xf08f"><i class="demo-icon icon-link-ext-alt">&#xf08f;</i> <span class="i-name">icon-link-ext-alt</span><span class="i-code">0xf08f</span></div>
<div class="the-icons span3" title="Code: 0xf0c9"><i class="demo-icon icon-menu">&#xf0c9;</i> <span class="i-name">icon-menu</span><span class="i-code">0xf0c9</span></div>
</div>
<div class="row">
<div class="the-icons span3" title="Code: 0xf0e0"><i class="demo-icon icon-mail-alt">&#xf0e0;</i> <span class="i-name">icon-mail-alt</span><span class="i-code">0xf0e0</span></div>
<div class="the-icons span3" title="Code: 0xf0e5"><i class="demo-icon icon-comment-empty">&#xf0e5;</i> <span class="i-name">icon-comment-empty</span><span class="i-code">0xf0e5</span></div>
</div>
<div class="row">
<div class="the-icons span3" title="Code: 0xf0f3"><i class="demo-icon icon-bell-alt">&#xf0f3;</i> <span class="i-name">icon-bell-alt</span><span class="i-code">0xf0f3</span></div>
<div class="the-icons span3" title="Code: 0xf0fe"><i class="demo-icon icon-plus-squared">&#xf0fe;</i> <span class="i-name">icon-plus-squared</span><span class="i-code">0xf0fe</span></div>
<div class="the-icons span3" title="Code: 0xf112"><i class="demo-icon icon-reply">&#xf112;</i> <span class="i-name">icon-reply</span><span class="i-code">0xf112</span></div>
<div class="the-icons span3" title="Code: 0xf13e"><i class="demo-icon icon-lock-open-alt">&#xf13e;</i> <span class="i-name">icon-lock-open-alt</span><span class="i-code">0xf13e</span></div>
</div>
<div class="row">
<div class="the-icons span3" title="Code: 0xf112"><i class="demo-icon icon-reply">&#xf112;</i> <span class="i-name">icon-reply</span><span class="i-code">0xf112</span></div>
<div class="the-icons span3" title="Code: 0xf13e"><i class="demo-icon icon-lock-open-alt">&#xf13e;</i> <span class="i-name">icon-lock-open-alt</span><span class="i-code">0xf13e</span></div>
<div class="the-icons span3" title="Code: 0xf144"><i class="demo-icon icon-play-circled">&#xf144;</i> <span class="i-name">icon-play-circled</span><span class="i-code">0xf144</span></div>
<div class="the-icons span3" title="Code: 0xf164"><i class="demo-icon icon-thumbs-up-alt">&#xf164;</i> <span class="i-name">icon-thumbs-up-alt</span><span class="i-code">0xf164</span></div>
</div>
<div class="row">
<div class="the-icons span3" title="Code: 0xf1e5"><i class="demo-icon icon-binoculars">&#xf1e5;</i> <span class="i-name">icon-binoculars</span><span class="i-code">0xf1e5</span></div>
<div class="the-icons span3" title="Code: 0xf234"><i class="demo-icon icon-user-plus">&#xf234;</i> <span class="i-name">icon-user-plus</span><span class="i-code">0xf234</span></div>
</div>

Binary file not shown.

View File

@ -56,6 +56,10 @@
<glyph glyph-name="pencil" unicode="&#xe818;" d="M203 0l50 51-131 131-51-51v-60h72v-71h60z m291 518q0 12-12 12-5 0-9-4l-303-302q-4-4-4-10 0-12 13-12 5 0 9 4l303 302q3 4 3 10z m-30 107l232-232-464-465h-232v233z m381-54q0-29-20-50l-93-93-232 233 93 92q20 21 50 21 29 0 51-21l131-131q20-22 20-51z" horiz-adv-x="857.1" />
<glyph glyph-name="verified" unicode="&#xe819;" d="M926 453l-19 13c-21 14-30 41-23 65l6 22c10 34-13 69-48 75l-23 4c-25 4-45 23-49 48l-4 23c-6 35-41 57-75 47l-22-7c-24-7-51 2-65 22l-14 20c-21 29-62 33-88 9l-17-16c-19-17-46-21-69-8l-20 11c-31 17-70 3-84-30l-9-22c-9-24-33-39-58-37l-23 1c-36 2-65-28-62-63l2-23c2-25-13-49-36-59l-21-9c-33-14-46-53-29-84l12-20c13-22 10-50-7-69l-15-17c-24-27-19-68 11-88l19-13c21-14 30-41 23-65l-9-23c-10-34 13-69 48-75l23-4c25-4 45-23 49-48l4-23c6-35 41-57 75-47l22 7c24 7 51-2 65-22l14-19c21-29 62-33 88-9l17 16c19 17 46 21 69 8l20-11c31-17 70-3 84 30l9 22c9 24 33 39 58 37l23-1c36-2 65 28 62 63l-1 23c-2 25 13 49 36 59l21 9c33 14 46 53 29 84l-12 20c-13 22-10 50 7 69l15 17c25 26 20 68-9 88z m-399-189l-82-81-81 82-78 79 82 81 78-79 187 186 81-82-187-186z" horiz-adv-x="1000" />
<glyph glyph-name="wrench" unicode="&#xe81a;" d="M214 36q0 14-10 25t-25 10-25-10-11-25 11-25 25-11 25 11 10 25z m360 234l-381-381q-21-20-50-20-29 0-51 20l-59 61q-21 20-21 50 0 29 21 51l380 380q22-55 64-97t97-64z m354 243q0-22-13-59-27-75-92-122t-144-46q-104 0-177 73t-73 177 73 176 177 74q32 0 67-10t60-26q9-6 9-15t-9-16l-163-94v-125l108-60q2 2 44 27t75 45 40 20q8 0 13-5t5-14z" horiz-adv-x="928.6" />
<glyph glyph-name="spin3" unicode="&#xe832;" d="M494 857c-266 0-483-210-494-472-1-19 13-20 13-20l84 0c16 0 19 10 19 18 10 199 176 358 378 358 107 0 205-45 273-118l-58-57c-11-12-11-27 5-31l247-50c21-5 46 11 37 44l-58 227c-2 9-16 22-29 13l-65-60c-89 91-214 148-352 148z m409-508c-16 0-19-10-19-18-10-199-176-358-377-358-108 0-205 45-274 118l59 57c10 12 10 27-5 31l-248 50c-21 5-46-11-37-44l58-227c2-9 16-22 30-13l64 60c89-91 214-148 353-148 265 0 482 210 493 473 1 18-13 19-13 19l-84 0z" horiz-adv-x="1000" />
<glyph glyph-name="spin4" unicode="&#xe834;" d="M498 857c-114 0-228-39-320-116l0 0c173 140 428 130 588-31 134-134 164-332 89-495-10-29-5-50 12-68 21-20 61-23 84 0 3 3 12 15 15 24 71 180 33 393-112 539-99 98-228 147-356 147z m-409-274c-14 0-29-5-39-16-3-3-13-15-15-24-71-180-34-393 112-539 185-185 479-195 676-31l0 0c-173-140-428-130-589 31-134 134-163 333-89 495 11 29 6 50-12 68-11 11-27 17-44 16z" horiz-adv-x="1001" />

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -58,7 +58,10 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639",
"url": "https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -127,7 +130,10 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/6634d32b-96a8-4852-a3db-ac8730715779",
"url": "https://shigusegubu.club/objects/6634d32b-96a8-4852-a3db-ac8730715779",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -250,7 +256,10 @@
"tags": [],
"uri": "https://pleroma.soykaf.com/objects/bf7e43d4-5048-4176-8519-58e3e1014f8b",
"url": "https://pleroma.soykaf.com/objects/bf7e43d4-5048-4176-8519-58e3e1014f8b",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
},
"reblogged": false,
"reblogs_count": 0,
@ -260,7 +269,10 @@
"tags": [],
"uri": "https://pleroma.soykaf.com/objects/bf7e43d4-5048-4176-8519-58e3e1014f8b",
"url": "https://pleroma.soykaf.com/objects/bf7e43d4-5048-4176-8519-58e3e1014f8b",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -329,7 +341,10 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/0f963ca1-a263-41ca-a43c-b5d26d0a08e9",
"url": "https://shigusegubu.club/objects/0f963ca1-a263-41ca-a43c-b5d26d0a08e9",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -390,7 +405,10 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/3f809bd8-656f-4a29-81d4-80eed6916eb0",
"url": "https://shigusegubu.club/objects/3f809bd8-656f-4a29-81d4-80eed6916eb0",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -516,7 +534,10 @@
}],
"uri": "tag:social.super-niche.club,2019-01-17:noticeId=2353002:objectType=note",
"url": "https://social.super-niche.club/notice/2353002",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
},
"reblogged": false,
"reblogs_count": 0,
@ -529,7 +550,10 @@
}],
"uri": "tag:social.super-niche.club,2019-01-17:noticeId=2353002:objectType=note",
"url": "tag:social.super-niche.club,2019-01-17:noticeId=2353002:objectType=note",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -657,7 +681,10 @@
"tags": [],
"uri": "https://miniwa.moe/objects/448e2944-0ecd-457f-92c3-cb454f2b0fab",
"url": "https://miniwa.moe/objects/448e2944-0ecd-457f-92c3-cb454f2b0fab",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
},
"reblogged": false,
"reblogs_count": 0,
@ -667,7 +694,10 @@
"tags": [],
"uri": "https://miniwa.moe/objects/448e2944-0ecd-457f-92c3-cb454f2b0fab",
"url": "https://miniwa.moe/objects/448e2944-0ecd-457f-92c3-cb454f2b0fab",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -733,7 +763,10 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/38b1bc44-15d8-40dd-b1aa-937e0ff4a86d",
"url": "https://shigusegubu.club/objects/38b1bc44-15d8-40dd-b1aa-937e0ff4a86d",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -794,7 +827,10 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/fbff5da4-a517-42a9-bca9-17cae8cf2542",
"url": "https://shigusegubu.club/objects/fbff5da4-a517-42a9-bca9-17cae8cf2542",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -850,7 +886,10 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/4007d659-27c6-4577-be10-fd134f5e4e7e",
"url": "https://shigusegubu.club/objects/4007d659-27c6-4577-be10-fd134f5e4e7e",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -906,7 +945,10 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/59912d51-1cc6-4dc7-828c-f167e6c8b391",
"url": "https://shigusegubu.club/objects/59912d51-1cc6-4dc7-828c-f167e6c8b391",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -962,7 +1004,10 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/62690bce-3f49-4047-9c8e-8941f2f79e10",
"url": "https://shigusegubu.club/objects/62690bce-3f49-4047-9c8e-8941f2f79e10",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -1023,7 +1068,10 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/818f3dd0-2ff8-4def-a170-e4d4c405f387",
"url": "https://shigusegubu.club/objects/818f3dd0-2ff8-4def-a170-e4d4c405f387",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -1084,7 +1132,10 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/0783a193-c097-488d-8944-47df9372cd6e",
"url": "https://shigusegubu.club/objects/0783a193-c097-488d-8944-47df9372cd6e",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -1145,7 +1196,10 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/145d5252-7b8e-467d-9f36-1db0818f452f",
"url": "https://shigusegubu.club/objects/145d5252-7b8e-467d-9f36-1db0818f452f",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -1252,7 +1306,10 @@
"tags": [],
"uri": "https://pleroma.site/objects/3076c055-0e34-4cf9-86ca-2d148b9b694a",
"url": "https://pleroma.site/objects/3076c055-0e34-4cf9-86ca-2d148b9b694a",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
},
"reblogged": false,
"reblogs_count": 0,
@ -1262,7 +1319,10 @@
"tags": [],
"uri": "https://pleroma.site/objects/3076c055-0e34-4cf9-86ca-2d148b9b694a",
"url": "https://pleroma.site/objects/3076c055-0e34-4cf9-86ca-2d148b9b694a",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -1323,7 +1383,10 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/d4eb7c46-02f9-4b1f-83af-926cefa21f33",
"url": "https://shigusegubu.club/objects/d4eb7c46-02f9-4b1f-83af-926cefa21f33",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -1446,7 +1509,10 @@
"tags": [],
"uri": "https://pleroma.soykaf.com/objects/338b6bd2-3c2d-40fe-93a3-28b688782733",
"url": "https://pleroma.soykaf.com/objects/338b6bd2-3c2d-40fe-93a3-28b688782733",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
},
"reblogged": false,
"reblogs_count": 0,
@ -1456,7 +1522,10 @@
"tags": [],
"uri": "https://pleroma.soykaf.com/objects/338b6bd2-3c2d-40fe-93a3-28b688782733",
"url": "https://pleroma.soykaf.com/objects/338b6bd2-3c2d-40fe-93a3-28b688782733",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -1517,7 +1586,10 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/f472f4ed-8b0b-492f-9d53-d69eda79629d",
"url": "https://shigusegubu.club/objects/f472f4ed-8b0b-492f-9d53-d69eda79629d",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}, {
"account": {
"acct": "hj",
@ -1578,5 +1650,8 @@
"tags": [],
"uri": "https://shigusegubu.club/objects/d6fb4fd2-1f6a-4446-a1a6-5edd34050096",
"url": "https://shigusegubu.club/objects/d6fb4fd2-1f6a-4446-a1a6-5edd34050096",
"visibility": "public"
"visibility": "public",
"pleroma": {
"local": true
}
}]

View File

@ -129,7 +129,10 @@ const makeMockStatusMasto = (overrides = {}) => {
tags: [],
uri: 'https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639',
url: 'https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639',
visibility: 'public'
visibility: 'public',
pleroma: {
local: true
}
}, overrides)
}

View File

@ -9,14 +9,17 @@ describe('NotificationUtils', () => {
notifications: {
data: [
{
id: 1,
action: { id: '1' },
type: 'like'
},
{
id: 2,
action: { id: '2' },
type: 'mention'
},
{
id: 3,
action: { id: '3' },
type: 'repeat'
}
@ -35,10 +38,12 @@ describe('NotificationUtils', () => {
const expected = [
{
action: { id: '3' },
id: 3,
type: 'repeat'
},
{
action: { id: '1' },
id: 1,
type: 'like'
}
]

View File

@ -6097,6 +6097,10 @@ pkg-dir@^3.0.0:
dependencies:
find-up "^3.0.0"
popper.js@^1.14.7:
version "1.14.7"
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.7.tgz#e31ec06cfac6a97a53280c3e55e4e0c860e7738e"
posix-character-classes@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
@ -8068,6 +8072,10 @@ uuid@^3.3.2:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==
v-click-outside@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/v-click-outside/-/v-click-outside-2.1.1.tgz#5af80b68a1c82eac89c597890434fa3994b42ed1"
validate-npm-package-license@^3.0.1:
version "3.0.4"
resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
@ -8141,6 +8149,10 @@ vue-hot-reload-api@^2.2.0:
resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.3.tgz#2756f46cb3258054c5f4723de8ae7e87302a1ccf"
integrity sha512-KmvZVtmM26BQOMK1rwUZsrqxEGeKiYSZGA7SNWE6uExx8UX/cj9hq2MRV/wWC3Cq6AoeDGk57rL9YMFRel/q+g==
vue-hot-reload-api@^2.0.11:
version "2.3.1"
resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.1.tgz#b2d3d95402a811602380783ea4f566eb875569a2"
vue-i18n@^7.3.2:
version "7.8.1"
resolved "https://registry.yarnpkg.com/vue-i18n/-/vue-i18n-7.8.1.tgz#2ce4b6efde679a1e05ddb5d907bfc1bc218803b2"
@ -8165,6 +8177,12 @@ vue-loader@^14.0.0:
vue-style-loader "^4.0.1"
vue-template-es2015-compiler "^1.6.0"
vue-popperjs@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/vue-popperjs/-/vue-popperjs-2.0.3.tgz#7c446d0ba7c63170ccb33a02669d0df4efc3d8cd"
dependencies:
popper.js "^1.14.7"
vue-router@^3.0.1:
version "3.0.2"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.2.tgz#dedc67afe6c4e2bc25682c8b1c2a8c0d7c7e56be"