Page MenuHomePhorge

No OneTemporary

Size
152 KB
Referenced Files
None
Subscribers
None
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 9fb9a853cd..34f6d6e7c5 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -1,331 +1,334 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import routes from './routes'
import App from '../App.vue'
import { windowWidth } from '../services/window_utils/window_utils'
import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import { applyTheme } from '../services/style_setter/style_setter.js'
const getStatusnetConfig = async ({ store }) => {
try {
const res = await window.fetch('/api/statusnet/config.json')
if (res.ok) {
const data = await res.json()
const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey, safeDMMentionsEnabled } = data.site
store.dispatch('setInstanceOption', { name: 'name', value: name })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') })
store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) })
store.dispatch('setInstanceOption', { name: 'server', value: server })
store.dispatch('setInstanceOption', { name: 'safeDM', value: safeDMMentionsEnabled !== '0' })
// TODO: default values for this stuff, added if to not make it break on
// my dev config out of the box.
if (uploadlimit) {
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadlimit.uploadlimit) })
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadlimit.avatarlimit) })
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadlimit.backgroundlimit) })
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadlimit.bannerlimit) })
}
if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
}
return data.site.pleromafe
} else {
throw (res)
}
} catch (error) {
console.error('Could not load statusnet config, potentially fatal')
console.error(error)
}
}
const getStaticConfig = async () => {
try {
const res = await window.fetch('/static/config.json')
if (res.ok) {
return res.json()
} else {
throw (res)
}
} catch (error) {
console.warn('Failed to load static/config.json, continuing without it.')
console.warn(error)
return {}
}
}
const setSettings = async ({ apiConfig, staticConfig, store }) => {
const overrides = window.___pleromafe_dev_overrides || {}
const env = window.___pleromafe_mode.NODE_ENV
// This takes static config and overrides properties that are present in apiConfig
let config = {}
if (overrides.staticConfigPreference && env === 'development') {
console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
config = Object.assign({}, apiConfig, staticConfig)
} else {
config = Object.assign({}, staticConfig, apiConfig)
}
const copyInstanceOption = (name) => {
store.dispatch('setInstanceOption', { name, value: config[name] })
}
copyInstanceOption('nsfwCensorImage')
copyInstanceOption('background')
copyInstanceOption('hidePostStats')
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo')
store.dispatch('setInstanceOption', {
name: 'logoMask',
value: typeof config.logoMask === 'undefined'
? true
: config.logoMask
})
store.dispatch('setInstanceOption', {
name: 'logoMargin',
value: typeof config.logoMargin === 'undefined'
? 0
: config.logoMargin
})
store.commit('authFlow/setInitialStrategy', config.loginMethod)
copyInstanceOption('redirectRootNoLogin')
copyInstanceOption('redirectRootLogin')
copyInstanceOption('showInstanceSpecificPanel')
copyInstanceOption('minimalScopesMode')
copyInstanceOption('hideMutedPosts')
copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('scopeCopy')
copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('noAttachmentLinks')
copyInstanceOption('showFeaturesPanel')
copyInstanceOption('hideSitename')
return store.dispatch('setTheme', config['theme'])
}
const getTOS = async ({ store }) => {
try {
const res = await window.fetch('/static/terms-of-service.html')
if (res.ok) {
const html = await res.text()
store.dispatch('setInstanceOption', { name: 'tos', value: html })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load TOS")
console.warn(e)
}
}
const getInstancePanel = async ({ store }) => {
try {
const res = await window.fetch('/instance/panel.html')
if (res.ok) {
const html = await res.text()
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load instance panel")
console.warn(e)
}
}
const getStickers = async ({ store }) => {
try {
const res = await window.fetch('/static/stickers.json')
if (res.ok) {
const values = await res.json()
const stickers = (await Promise.all(
Object.entries(values).map(async ([name, path]) => {
const resPack = await window.fetch(path + 'pack.json')
var meta = {}
if (resPack.ok) {
meta = await resPack.json()
}
return {
pack: name,
path,
meta
}
})
)).sort((a, b) => {
return a.meta.title.localeCompare(b.meta.title)
})
store.dispatch('setInstanceOption', { name: 'stickers', value: stickers })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load stickers")
console.warn(e)
}
}
const getAppSecret = async ({ store }) => {
const { state, commit } = store
const { oauth, instance } = state
return getOrCreateApp({ ...oauth, instance: instance.server, commit })
.then((app) => getClientToken({ ...app, instance: instance.server }))
.then((token) => {
commit('setAppToken', token.access_token)
commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
})
}
const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map(uri => uri.split('/').pop())
nicknames.map(nickname => store.dispatch('fetchUser', nickname))
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
}
const getNodeInfo = async ({ store }) => {
try {
const res = await window.fetch('/nodeinfo/2.0.json')
if (res.ok) {
const data = await res.json()
const metadata = data.metadata
const features = metadata.features
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
const suggestions = metadata.suggestions
store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled })
store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web })
const software = data.software
store.dispatch('setInstanceOption', { name: 'backendVersion', value: software.version })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: software.name === 'pleroma' })
const priv = metadata.private
store.dispatch('setInstanceOption', { name: 'private', value: priv })
const frontendVersion = window.___pleromafe_commit_hash
store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion })
const federation = metadata.federation
store.dispatch('setInstanceOption', {
name: 'tagPolicyAvailable',
value: typeof federation.mrf_policies === 'undefined'
? false
: metadata.federation.mrf_policies.includes('TagPolicy')
})
store.dispatch('setInstanceOption', { name: 'federationPolicy', value: federation })
store.dispatch('setInstanceOption', {
name: 'federating',
value: typeof federation.enabled === 'undefined'
? true
: federation.enabled
})
const accountActivationRequired = metadata.accountActivationRequired
store.dispatch('setInstanceOption', { name: 'accountActivationRequired', value: accountActivationRequired })
const accounts = metadata.staffAccounts
resolveStaffAccounts({ store, accounts })
} else {
throw (res)
}
} catch (e) {
console.warn('Could not load nodeinfo')
console.warn(e)
}
}
const setConfig = async ({ store }) => {
// apiConfig, staticConfig
const configInfos = await Promise.all([getStatusnetConfig({ store }), getStaticConfig()])
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
await setSettings({ store, apiConfig, staticConfig }).then(getAppSecret({ store }))
}
const checkOAuthToken = async ({ store }) => {
return new Promise(async (resolve, reject) => {
if (store.getters.getUserToken()) {
try {
await store.dispatch('loginUser', store.getters.getUserToken())
} catch (e) {
console.error(e)
}
}
resolve()
})
}
const afterStoreSetup = async ({ store, i18n }) => {
const width = windowWidth()
store.dispatch('setMobileLayout', width <= 800)
await setConfig({ store })
const { customTheme, customThemeSource } = store.state.config
const { theme } = store.state.instance
const customThemePresent = customThemeSource || customTheme
if (customThemePresent) {
if (customThemeSource && customThemeSource.themeEngineVersion === CURRENT_VERSION) {
applyTheme(customThemeSource)
} else {
applyTheme(customTheme)
}
} else if (theme) {
// do nothing, it will load asynchronously
} else {
console.error('Failed to load any theme!')
}
// Now we can try getting the server settings and logging in
await Promise.all([
checkOAuthToken({ store }),
getTOS({ store }),
getInstancePanel({ store }),
getStickers({ store }),
getNodeInfo({ store })
])
+ // Start fetching things that don't need to block the UI
+ store.dispatch('fetchMutes')
+
const router = new VueRouter({
mode: 'history',
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some(m => m.meta.dontScroll)) {
return false
}
return savedPosition || { x: 0, y: 0 }
}
})
/* eslint-disable no-new */
return new Vue({
router,
store,
i18n,
el: '#app',
render: h => h(App)
})
}
export default afterStoreSetup
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index 5d7ecf7e46..0826c27583 100644
--- a/src/components/account_actions/account_actions.js
+++ b/src/components/account_actions/account_actions.js
@@ -1,34 +1,34 @@
import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue'
const AccountActions = {
props: [
- 'user'
+ 'user', 'relationship'
],
data () {
return { }
},
components: {
ProgressButton,
Popover
},
methods: {
showRepeats () {
this.$store.dispatch('showReblogs', this.user.id)
},
hideRepeats () {
this.$store.dispatch('hideReblogs', this.user.id)
},
blockUser () {
this.$store.dispatch('blockUser', this.user.id)
},
unblockUser () {
this.$store.dispatch('unblockUser', this.user.id)
},
reportUser () {
this.$store.dispatch('openUserReportingModal', this.user.id)
}
}
}
export default AccountActions
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index 483783cfb5..744b77d539 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -1,86 +1,86 @@
<template>
<div class="account-actions">
<Popover
trigger="click"
placement="bottom"
>
<div
slot="content"
class="account-tools-popover"
>
<div class="dropdown-menu">
- <template v-if="user.following">
+ <template v-if="relationship.following">
<button
- v-if="user.showing_reblogs"
+ v-if="relationship.showing_reblogs"
class="btn btn-default dropdown-item"
@click="hideRepeats"
>
{{ $t('user_card.hide_repeats') }}
</button>
<button
- v-if="!user.showing_reblogs"
+ v-if="!relationship.showing_reblogs"
class="btn btn-default dropdown-item"
@click="showRepeats"
>
{{ $t('user_card.show_repeats') }}
</button>
<div
role="separator"
class="dropdown-divider"
/>
</template>
<button
- v-if="user.statusnet_blocking"
+ v-if="relationship.blocking"
class="btn btn-default btn-block dropdown-item"
@click="unblockUser"
>
{{ $t('user_card.unblock') }}
</button>
<button
v-else
class="btn btn-default btn-block dropdown-item"
@click="blockUser"
>
{{ $t('user_card.block') }}
</button>
<button
class="btn btn-default btn-block dropdown-item"
@click="reportUser"
>
{{ $t('user_card.report') }}
</button>
</div>
</div>
<div
slot="trigger"
class="btn btn-default ellipsis-button"
>
<i class="icon-ellipsis trigger-button" />
</div>
</Popover>
</div>
</template>
<script src="./account_actions.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.account-actions {
margin: 0 .8em;
}
.account-actions button.dropdown-item {
margin-left: 0;
}
.account-actions .trigger-button {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
opacity: .8;
cursor: pointer;
&:hover {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
</style>
diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue
index 8a02174e0a..9e4106102d 100644
--- a/src/components/basic_user_card/basic_user_card.vue
+++ b/src/components/basic_user_card/basic_user_card.vue
@@ -1,93 +1,93 @@
<template>
<div class="basic-user-card">
<router-link :to="userProfileLink(user)">
<UserAvatar
class="avatar"
:user="user"
@click.prevent.native="toggleUserExpanded"
/>
</router-link>
<div
v-if="userExpanded"
class="basic-user-card-expanded-content"
>
<UserCard
- :user="user"
+ :user-id="user.id"
:rounded="true"
:bordered="true"
/>
</div>
<div
v-else
class="basic-user-card-collapsed-content"
>
<div
:title="user.name"
class="basic-user-card-user-name"
>
<!-- eslint-disable vue/no-v-html -->
<span
v-if="user.name_html"
class="basic-user-card-user-name-value"
v-html="user.name_html"
/>
<!-- eslint-enable vue/no-v-html -->
<span
v-else
class="basic-user-card-user-name-value"
>{{ user.name }}</span>
</div>
<div>
<router-link
class="basic-user-card-screen-name"
:to="userProfileLink(user)"
>
@{{ user.screen_name }}
</router-link>
</div>
<slot />
</div>
</div>
</template>
<script src="./basic_user_card.js"></script>
<style lang="scss">
.basic-user-card {
display: flex;
flex: 1 0;
margin: 0;
padding: 0.6em 1em;
&-collapsed-content {
margin-left: 0.7em;
text-align: left;
flex: 1;
min-width: 0;
}
&-user-name {
img {
object-fit: contain;
height: 16px;
width: 16px;
vertical-align: middle;
}
}
&-user-name-value,
&-screen-name {
display: inline-block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&-expanded-content {
flex: 1;
margin-left: 0.7em;
min-width: 0;
}
}
</style>
diff --git a/src/components/block_card/block_card.js b/src/components/block_card/block_card.js
index c459ff1b51..0bf4e37bc0 100644
--- a/src/components/block_card/block_card.js
+++ b/src/components/block_card/block_card.js
@@ -1,37 +1,40 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const BlockCard = {
props: ['userId'],
data () {
return {
progress: false
}
},
computed: {
user () {
return this.$store.getters.findUser(this.userId)
},
+ relationship () {
+ return this.$store.getters.relationship(this.userId)
+ },
blocked () {
- return this.user.statusnet_blocking
+ return this.relationship.blocking
}
},
components: {
BasicUserCard
},
methods: {
unblockUser () {
this.progress = true
this.$store.dispatch('unblockUser', this.user.id).then(() => {
this.progress = false
})
},
blockUser () {
this.progress = true
this.$store.dispatch('blockUser', this.user.id).then(() => {
this.progress = false
})
}
}
}
export default BlockCard
diff --git a/src/components/follow_button/follow_button.js b/src/components/follow_button/follow_button.js
index 12da264545..95e7cb6ba7 100644
--- a/src/components/follow_button/follow_button.js
+++ b/src/components/follow_button/follow_button.js
@@ -1,53 +1,53 @@
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
export default {
- props: ['user', 'labelFollowing', 'buttonClass'],
+ props: ['relationship', 'labelFollowing', 'buttonClass'],
data () {
return {
inProgress: false
}
},
computed: {
isPressed () {
- return this.inProgress || this.user.following
+ return this.inProgress || this.relationship.following
},
title () {
- if (this.inProgress || this.user.following) {
+ if (this.inProgress || this.relationship.following) {
return this.$t('user_card.follow_unfollow')
- } else if (this.user.requested) {
+ } else if (this.relationship.requested) {
return this.$t('user_card.follow_again')
} else {
return this.$t('user_card.follow')
}
},
label () {
if (this.inProgress) {
return this.$t('user_card.follow_progress')
- } else if (this.user.following) {
+ } else if (this.relationship.following) {
return this.labelFollowing || this.$t('user_card.following')
- } else if (this.user.requested) {
+ } else if (this.relationship.requested) {
return this.$t('user_card.follow_sent')
} else {
return this.$t('user_card.follow')
}
}
},
methods: {
onClick () {
- this.user.following ? this.unfollow() : this.follow()
+ this.relationship.following ? this.unfollow() : this.follow()
},
follow () {
this.inProgress = true
- requestFollow(this.user, this.$store).then(() => {
+ requestFollow(this.relationship.id, this.$store).then(() => {
this.inProgress = false
})
},
unfollow () {
const store = this.$store
this.inProgress = true
- requestUnfollow(this.user, store).then(() => {
+ requestUnfollow(this.relationship.id, store).then(() => {
this.inProgress = false
- store.commit('removeStatus', { timeline: 'friends', userId: this.user.id })
+ store.commit('removeStatus', { timeline: 'friends', userId: this.relationship.id })
})
}
}
}
diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js
index aefd609e02..6dcb6d47bd 100644
--- a/src/components/follow_card/follow_card.js
+++ b/src/components/follow_card/follow_card.js
@@ -1,25 +1,28 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import FollowButton from '../follow_button/follow_button.vue'
const FollowCard = {
props: [
'user',
'noFollowsYou'
],
components: {
BasicUserCard,
RemoteFollow,
FollowButton
},
computed: {
isMe () {
return this.$store.state.users.currentUser.id === this.user.id
},
loggedIn () {
return this.$store.state.users.currentUser
+ },
+ relationship () {
+ return this.$store.getters.relationship(this.user.id)
}
}
}
export default FollowCard
diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue
index 81e6e6dc0e..76a7073007 100644
--- a/src/components/follow_card/follow_card.vue
+++ b/src/components/follow_card/follow_card.vue
@@ -1,48 +1,48 @@
<template>
<basic-user-card :user="user">
<div class="follow-card-content-container">
<span
- v-if="!noFollowsYou && user.follows_you"
+ v-if="!noFollowsYou && relationship.followed_by"
class="faint"
>
{{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span>
<template v-if="!loggedIn">
<div
- v-if="!user.following"
+ v-if="!relationship.following"
class="follow-card-follow-button"
>
<RemoteFollow :user="user" />
</div>
</template>
<template v-else>
<FollowButton
- :user="user"
- class="follow-card-follow-button"
+ :relationship="relationship"
:label-following="$t('user_card.follow_unfollow')"
+ class="follow-card-follow-button"
/>
</template>
</div>
</basic-user-card>
</template>
<script src="./follow_card.js"></script>
<style lang="scss">
.follow-card {
&-content-container {
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
flex-wrap: wrap;
line-height: 1.5em;
}
&-follow-button {
margin-top: 0.5em;
margin-left: auto;
width: 10em;
}
}
</style>
diff --git a/src/components/mute_card/mute_card.js b/src/components/mute_card/mute_card.js
index 65c9cfb5b0..cbec0e9bb3 100644
--- a/src/components/mute_card/mute_card.js
+++ b/src/components/mute_card/mute_card.js
@@ -1,37 +1,40 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const MuteCard = {
props: ['userId'],
data () {
return {
progress: false
}
},
computed: {
user () {
return this.$store.getters.findUser(this.userId)
},
+ relationship () {
+ return this.$store.getters.relationship(this.userId)
+ },
muted () {
- return this.user.muted
+ return this.relationship.muting
}
},
components: {
BasicUserCard
},
methods: {
unmuteUser () {
this.progress = true
- this.$store.dispatch('unmuteUser', this.user.id).then(() => {
+ this.$store.dispatch('unmuteUser', this.userId).then(() => {
this.progress = false
})
},
muteUser () {
this.progress = true
- this.$store.dispatch('muteUser', this.user.id).then(() => {
+ this.$store.dispatch('muteUser', this.userId).then(() => {
this.progress = false
})
}
}
}
export default MuteCard
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 1ae81ce4d1..1cf4c9bc7b 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -1,86 +1,86 @@
import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const Notification = {
data () {
return {
userExpanded: false,
betterShadow: this.$store.state.interface.browserSupport.cssFilter,
unmuted: false
}
},
props: [ 'notification' ],
components: {
Status,
UserAvatar,
UserCard,
Timeago
},
methods: {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
generateUserProfileLink (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]
},
toggleMute () {
this.unmuted = !this.unmuted
},
approveUser () {
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
this.$store.dispatch('markSingleNotificationAsSeen', { id: this.notification.id })
this.$store.dispatch('updateNotification', {
id: this.notification.id,
updater: notification => {
notification.type = 'follow'
}
})
},
denyUser () {
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
.then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: this.notification.id })
this.$store.dispatch('removeFollowRequest', this.user)
})
}
},
computed: {
userClass () {
return highlightClass(this.notification.from_profile)
},
userStyle () {
const highlight = this.$store.getters.mergedConfig.highlight
const user = this.notification.from_profile
return highlightStyle(highlight[user.screen_name])
},
user () {
return this.$store.getters.findUser(this.notification.from_profile.id)
},
userProfileLink () {
return this.generateUserProfileLink(this.user)
},
targetUser () {
return this.$store.getters.findUser(this.notification.target.id)
},
targetUserProfileLink () {
return this.generateUserProfileLink(this.targetUser)
},
needMute () {
- return this.user.muted
+ return this.$store.getters.relationship(this.user.id).muting
},
isStatusNotification () {
return isStatusNotification(this.notification.type)
}
}
}
export default Notification
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index f6da07ddc1..0e46a2a722 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -1,172 +1,172 @@
<template>
<status
v-if="notification.type === 'mention'"
:compact="true"
:statusoid="notification.status"
/>
<div v-else>
<div
v-if="needMute && !unmuted"
class="container muted"
>
<small>
<router-link :to="userProfileLink">
{{ notification.from_profile.screen_name }}
</router-link>
</small>
<a
href="#"
class="unmute"
@click.prevent="toggleMute"
><i class="button-icon icon-eye-off" /></a>
</div>
<div
v-else
class="non-mention"
:class="[userClass, { highlighted: userStyle }]"
:style="[ userStyle ]"
>
<a
class="avatar-container"
:href="notification.from_profile.statusnet_profile_url"
@click.stop.prevent.capture="toggleUserExpanded"
>
<UserAvatar
:compact="true"
:better-shadow="betterShadow"
:user="notification.from_profile"
/>
</a>
<div class="notification-right">
<UserCard
v-if="userExpanded"
- :user="getUser(notification)"
+ :user-id="getUser(notification).id"
:rounded="true"
:bordered="true"
/>
<span class="notification-details">
<div class="name-and-action">
<!-- eslint-disable vue/no-v-html -->
<bdi
v-if="!!notification.from_profile.name_html"
class="username"
:title="'@'+notification.from_profile.screen_name"
v-html="notification.from_profile.name_html"
/>
<!-- eslint-enable vue/no-v-html -->
<span
v-else
class="username"
:title="'@'+notification.from_profile.screen_name"
>{{ notification.from_profile.name }}</span>
<span v-if="notification.type === 'like'">
<i class="fa icon-star lit" />
<small>{{ $t('notifications.favorited_you') }}</small>
</span>
<span v-if="notification.type === 'repeat'">
<i
class="fa icon-retweet lit"
:title="$t('tool_tip.repeat')"
/>
<small>{{ $t('notifications.repeated_you') }}</small>
</span>
<span v-if="notification.type === 'follow'">
<i class="fa icon-user-plus lit" />
<small>{{ $t('notifications.followed_you') }}</small>
</span>
<span v-if="notification.type === 'follow_request'">
<i class="fa icon-user lit" />
<small>{{ $t('notifications.follow_request') }}</small>
</span>
<span v-if="notification.type === 'move'">
<i class="fa icon-arrow-curved lit" />
<small>{{ $t('notifications.migrated_to') }}</small>
</span>
<span v-if="notification.type === 'pleroma:emoji_reaction'">
<small>
<i18n path="notifications.reacted_with">
<span class="emoji-reaction-emoji">{{ notification.emoji }}</span>
</i18n>
</small>
</span>
</div>
<div
v-if="isStatusNotification"
class="timeago"
>
<router-link
v-if="notification.status"
:to="{ name: 'conversation', params: { id: notification.status.id } }"
class="faint-link"
>
<Timeago
:time="notification.created_at"
:auto-update="240"
/>
</router-link>
</div>
<div
v-else
class="timeago"
>
<span class="faint">
<Timeago
:time="notification.created_at"
:auto-update="240"
/>
</span>
</div>
<a
v-if="needMute"
href="#"
@click.prevent="toggleMute"
><i class="button-icon icon-eye-off" /></a>
</span>
<div
v-if="notification.type === 'follow' || notification.type === 'follow_request'"
class="follow-text"
>
<router-link
:to="userProfileLink"
class="follow-name"
>
@{{ notification.from_profile.screen_name }}
</router-link>
<div
v-if="notification.type === 'follow_request'"
style="white-space: nowrap;"
>
<i
class="icon-ok button-icon follow-request-accept"
:title="$t('tool_tip.accept_follow_request')"
@click="approveUser()"
/>
<i
class="icon-cancel button-icon follow-request-reject"
:title="$t('tool_tip.reject_follow_request')"
@click="denyUser()"
/>
</div>
</div>
<div
v-else-if="notification.type === 'move'"
class="move-text"
>
<router-link :to="targetUserProfileLink">
@{{ notification.target.screen_name }}
</router-link>
</div>
<template v-else>
<status
class="faint"
:compact="true"
:statusoid="notification.action"
:no-heading="true"
/>
</template>
</div>
</div>
</div>
</template>
<script src="./notification.js"></script>
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index df1d22a47e..2958a3869c 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -1,308 +1,308 @@
<template>
<div
class="side-drawer-container"
:class="{ 'side-drawer-container-closed': closed, 'side-drawer-container-open': !closed }"
>
<div
class="side-drawer-darken"
:class="{ 'side-drawer-darken-closed': closed}"
/>
<div
class="side-drawer"
:class="{'side-drawer-closed': closed}"
@touchstart="touchStart"
@touchmove="touchMove"
>
<div
class="side-drawer-heading"
@click="toggleDrawer"
>
<UserCard
v-if="currentUser"
- :user="currentUser"
+ :user-id="currentUser.id"
:hide-bio="true"
/>
<div
v-else
class="side-drawer-logo-wrapper"
>
<img :src="logo">
<span v-if="!hideSitename">{{ sitename }}</span>
</div>
</div>
<ul>
<li
v-if="!currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'login' }">
<i class="button-icon icon-login" /> {{ $t("login.login") }}
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
<i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }">
<i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }}
</router-link>
</li>
</ul>
<ul>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'friends' }">
<i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }}
</router-link>
</li>
<li
v-if="currentUser && currentUser.locked"
@click="toggleDrawer"
>
<router-link to="/friend-requests">
<i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }}
<span
v-if="followRequestCount > 0"
class="badge follow-request-count"
>
{{ followRequestCount }}
</span>
</router-link>
</li>
<li
v-if="currentUser || !privateMode"
@click="toggleDrawer"
>
<router-link to="/main/public">
<i class="button-icon icon-users" /> {{ $t("nav.public_tl") }}
</router-link>
</li>
<li
v-if="federating && (currentUser || !privateMode)"
@click="toggleDrawer"
>
<router-link to="/main/all">
<i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
</router-link>
</li>
<li
v-if="currentUser && chat"
@click="toggleDrawer"
>
<router-link :to="{ name: 'chat' }">
<i class="button-icon icon-chat" /> {{ $t("nav.chat") }}
</router-link>
</li>
</ul>
<ul>
<li
v-if="currentUser || !privateMode"
@click="toggleDrawer"
>
<router-link :to="{ name: 'search' }">
<i class="button-icon icon-search" /> {{ $t("nav.search") }}
</router-link>
</li>
<li
v-if="currentUser && suggestionsEnabled"
@click="toggleDrawer"
>
<router-link :to="{ name: 'who-to-follow' }">
<i class="button-icon icon-user-plus" /> {{ $t("nav.who_to_follow") }}
</router-link>
</li>
<li @click="toggleDrawer">
<router-link :to="{ name: 'settings' }">
<i class="button-icon icon-cog" /> {{ $t("settings.settings") }}
</router-link>
</li>
<li @click="toggleDrawer">
<router-link :to="{ name: 'about'}">
<i class="button-icon icon-info-circled" /> {{ $t("nav.about") }}
</router-link>
</li>
<li
v-if="currentUser && currentUser.role === 'admin'"
@click="toggleDrawer"
>
<a
href="/pleroma/admin/#/login-pleroma"
target="_blank"
>
<i class="button-icon icon-gauge" /> {{ $t("nav.administration") }}
</a>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<a
href="#"
@click="doLogout"
>
<i class="button-icon icon-logout" /> {{ $t("login.logout") }}
</a>
</li>
</ul>
</div>
<div
class="side-drawer-click-outside"
:class="{'side-drawer-click-outside-closed': closed}"
@click.stop.prevent="toggleDrawer"
/>
</div>
</template>
<script src="./side_drawer.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
.side-drawer-container {
position: fixed;
z-index: 1000;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: stretch;
transition-duration: 0s;
transition-property: transform;
}
.side-drawer-container-open {
transform: translate(0%);
}
.side-drawer-container-closed {
transition-delay: 0.35s;
transform: translate(-100%);
}
.side-drawer-darken {
top: 0;
left: 0;
width: 100vw;
height: 100vh;
position: fixed;
z-index: -1;
transition: 0.35s;
transition-property: background-color;
background-color: rgba(0, 0, 0, 0.5);
}
.side-drawer-darken-closed {
background-color: rgba(0, 0, 0, 0);
}
.side-drawer-click-outside {
flex: 1 1 100%;
}
.side-drawer {
overflow-x: hidden;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
transition: 0.35s;
transition-property: transform;
margin: 0 0 0 -100px;
padding: 0 0 1em 100px;
width: 80%;
max-width: 20em;
flex: 0 0 80%;
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
box-shadow: var(--panelShadow);
background-color: $fallback--bg;
background-color: var(--popover, $fallback--bg);
color: $fallback--link;
color: var(--popoverText, $fallback--link);
--faint: var(--popoverFaintText, $fallback--faint);
--faintLink: var(--popoverFaintLink, $fallback--faint);
--lightText: var(--popoverLightText, $fallback--lightText);
--icon: var(--popoverIcon, $fallback--icon);
.button-icon:before {
width: 1.1em;
}
}
.side-drawer-logo-wrapper {
display: flex;
align-items: center;
padding: 0.85em;
img {
flex: none;
height: 50px;
margin-right: 0.85em;
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.side-drawer-click-outside-closed {
flex: 0 0 0;
}
.side-drawer-closed {
transform: translate(-100%);
}
.side-drawer-heading {
background: transparent;
flex-direction: column;
align-items: stretch;
display: flex;
padding: 0;
margin: 0;
}
.side-drawer ul {
list-style: none;
margin: 0;
padding: 0;
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
margin: 0.2em 0;
}
.side-drawer ul:last-child {
border: 0;
}
.side-drawer li {
padding: 0;
a {
display: block;
padding: 0.5em 0.85em;
&:hover {
background-color: $fallback--lightBg;
background-color: var(--selectedMenuPopover, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedMenuPopoverText, $fallback--text);
--faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
--faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
--lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
--icon: var(--selectedMenuPopoverIcon, $fallback--icon);
}
}
}
</style>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index ab3de5fc19..9cd9d61c6d 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,283 +1,292 @@
import FavoriteButton from '../favorite_button/favorite_button.vue'
import ReactButton from '../react_button/react_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue'
import ExtraButtons from '../extra_buttons/extra_buttons.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import StatusContent from '../status_content/status_content.vue'
import StatusPopover from '../status_popover/status_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { filter, unescape, uniqBy } from 'lodash'
import { mapGetters, mapState } from 'vuex'
const Status = {
name: 'Status',
props: [
'statusoid',
'expandable',
'inConversation',
'focused',
'highlight',
'compact',
'replies',
'isPreview',
'noHeading',
'inlineExpanded',
'showPinned',
'inProfile',
'profileUserId'
],
data () {
return {
replying: false,
unmuted: false,
userExpanded: false,
error: null
}
},
computed: {
muteWords () {
return this.mergedConfig.muteWords
},
repeaterClass () {
const user = this.statusoid.user
return highlightClass(user)
},
userClass () {
const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user
return highlightClass(user)
},
deleted () {
return this.statusoid.deleted
},
repeaterStyle () {
const user = this.statusoid.user
const highlight = this.mergedConfig.highlight
return highlightStyle(highlight[user.screen_name])
},
userStyle () {
if (this.noHeading) return
const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user
const highlight = this.mergedConfig.highlight
return highlightStyle(highlight[user.screen_name])
},
userProfileLink () {
return this.generateUserProfileLink(this.status.user.id, this.status.user.screen_name)
},
replyProfileLink () {
if (this.isReply) {
return this.generateUserProfileLink(this.status.in_reply_to_user_id, this.replyToName)
}
},
retweet () { return !!this.statusoid.retweeted_status },
retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name },
retweeterHtml () { return this.statusoid.user.name_html },
retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) },
status () {
if (this.retweet) {
return this.statusoid.retweeted_status
} else {
return this.statusoid
}
},
statusFromGlobalRepository () {
// NOTE: Consider to replace status with statusFromGlobalRepository
return this.$store.state.statuses.allStatusesObject[this.status.id]
},
loggedIn () {
return !!this.currentUser
},
muteWordHits () {
const statusText = this.status.text.toLowerCase()
const statusSummary = this.status.summary.toLowerCase()
const hits = filter(this.muteWords, (muteWord) => {
return statusText.includes(muteWord.toLowerCase()) || statusSummary.includes(muteWord.toLowerCase())
})
return hits
},
- muted () { return !this.unmuted && ((!(this.inProfile && this.status.user.id === this.profileUserId) && this.status.user.muted) || (!this.inConversation && this.status.thread_muted) || this.muteWordHits.length > 0) },
+ muted () {
+ const relationship = this.$store.getters.relationship(this.status.user.id)
+ return !this.unmuted && (
+ (!(this.inProfile && this.status.user.id === this.profileUserId) && relationship.muting) ||
+ (!this.inConversation && this.status.thread_muted) ||
+ this.muteWordHits.length > 0)
+ },
hideFilteredStatuses () {
return this.mergedConfig.hideFilteredStatuses
},
hideStatus () {
return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses)
},
isFocused () {
// retweet or root of an expanded conversation
if (this.focused) {
return true
} else if (!this.inConversation) {
return false
}
// use conversation highlight only when in conversation
return this.status.id === this.highlight
},
isReply () {
return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id)
},
replyToName () {
if (this.status.in_reply_to_screen_name) {
return this.status.in_reply_to_screen_name
} else {
const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
return user && user.screen_name
}
},
hideReply () {
if (this.mergedConfig.replyVisibility === 'all') {
return false
}
if (this.inConversation || !this.isReply) {
return false
}
if (this.status.user.id === this.currentUser.id) {
return false
}
if (this.status.type === 'retweet') {
return false
}
const checkFollowing = this.mergedConfig.replyVisibility === 'following'
for (var i = 0; i < this.status.attentions.length; ++i) {
if (this.status.user.id === this.status.attentions[i].id) {
continue
}
- const taggedUser = this.$store.getters.findUser(this.status.attentions[i].id)
- if (checkFollowing && taggedUser && taggedUser.following) {
+ // There's zero guarantee of this working. If we happen to have that user and their
+ // relationship in store then it will work, but there's kinda little chance of having
+ // them for people you're not following.
+ const relationship = this.$store.state.users.relationships[this.status.attentions[i].id]
+ if (checkFollowing && relationship && relationship.following) {
return false
}
if (this.status.attentions[i].id === this.currentUser.id) {
return false
}
}
return this.status.attentions.length > 0
},
replySubject () {
if (!this.status.summary) return ''
const decodedSummary = unescape(this.status.summary)
const behavior = this.mergedConfig.subjectLineBehavior
const startsWithRe = decodedSummary.match(/^re[: ]/i)
if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') {
return decodedSummary
} else if (behavior === 'email') {
return 're: '.concat(decodedSummary)
} else if (behavior === 'noop') {
return ''
}
},
combinedFavsAndRepeatsUsers () {
// Use the status from the global status repository since favs and repeats are saved in it
const combinedUsers = [].concat(
this.statusFromGlobalRepository.favoritedBy,
this.statusFromGlobalRepository.rebloggedBy
)
return uniqBy(combinedUsers, 'id')
},
tags () {
return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ')
},
hidePostStats () {
return this.mergedConfig.hidePostStats
},
...mapGetters(['mergedConfig']),
...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter,
currentUser: state => state.users.currentUser
})
},
components: {
FavoriteButton,
ReactButton,
RetweetButton,
ExtraButtons,
PostStatusForm,
UserCard,
UserAvatar,
AvatarList,
Timeago,
StatusPopover,
EmojiReactions,
StatusContent
},
methods: {
visibilityIcon (visibility) {
switch (visibility) {
case 'private':
return 'icon-lock'
case 'unlisted':
return 'icon-lock-open-alt'
case 'direct':
return 'icon-mail-alt'
default:
return 'icon-globe'
}
},
showError (error) {
this.error = error
},
clearError () {
this.error = undefined
},
toggleReplying () {
this.replying = !this.replying
},
gotoOriginal (id) {
if (this.inConversation) {
this.$emit('goto', id)
}
},
toggleExpanded () {
this.$emit('toggleExpanded')
},
toggleMute () {
this.unmuted = !this.unmuted
},
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
generateUserProfileLink (id, name) {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
}
},
watch: {
'highlight': function (id) {
if (this.status.id === id) {
let rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.height >= (window.innerHeight - 50)) {
// Post we want to see is taller than screen so match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.bottom > window.innerHeight - 50) {
// Post is below screen, match its bottom to screen bottom
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (this.isFocused && this.statusFromGlobalRepository.rebloggedBy && this.statusFromGlobalRepository.rebloggedBy.length !== num) {
this.$store.dispatch('fetchRepeats', this.status.id)
}
},
'status.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (this.isFocused && this.statusFromGlobalRepository.favoritedBy && this.statusFromGlobalRepository.favoritedBy.length !== num) {
this.$store.dispatch('fetchFavs', this.status.id)
}
}
},
filters: {
capitalize: function (str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
}
}
export default Status
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 6d862ca0cd..e4c7545bbb 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,726 +1,726 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<div
v-if="!hideStatus"
class="status-el"
:class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"
>
<div
v-if="error"
class="alert error"
>
{{ error }}
<i
class="button-icon icon-cancel"
@click="clearError"
/>
</div>
<template v-if="muted && !isPreview">
<div class="media status container muted">
<small>
<router-link :to="userProfileLink">
{{ status.user.screen_name }}
</router-link>
</small>
<small class="muteWords">{{ muteWordHits.join(', ') }}</small>
<a
href="#"
class="unmute"
@click.prevent="toggleMute"
><i class="button-icon icon-eye-off" /></a>
</div>
</template>
<template v-else>
<div
v-if="showPinned"
class="status-pin"
>
<i class="fa icon-pin faint" />
<span class="faint">{{ $t('status.pinned') }}</span>
</div>
<div
v-if="retweet && !noHeading && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]"
class="media container retweet-info"
>
<UserAvatar
v-if="retweet"
class="media-left"
:better-shadow="betterShadow"
:user="statusoid.user"
/>
<div class="media-body faint">
<span class="user-name">
<router-link
v-if="retweeterHtml"
:to="retweeterProfileLink"
v-html="retweeterHtml"
/>
<router-link
v-else
:to="retweeterProfileLink"
>{{ retweeter }}</router-link>
</span>
<i
class="fa icon-retweet retweeted"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</div>
</div>
<div
:class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]"
:style="[ userStyle ]"
class="media status"
:data-tags="tags"
>
<div
v-if="!noHeading"
class="media-left"
>
<router-link
:to="userProfileLink"
@click.stop.prevent.capture.native="toggleUserExpanded"
>
<UserAvatar
:compact="compact"
:better-shadow="betterShadow"
:user="status.user"
/>
</router-link>
</div>
<div class="status-body">
<UserCard
v-if="userExpanded"
- :user="status.user"
+ :user-id="status.user.id"
:rounded="true"
:bordered="true"
class="status-usercard"
/>
<div
v-if="!noHeading"
class="media-heading"
>
<div class="heading-name-row">
<div class="name-and-account-name">
<h4
v-if="status.user.name_html"
class="user-name"
v-html="status.user.name_html"
/>
<h4
v-else
class="user-name"
>
{{ status.user.name }}
</h4>
<router-link
class="account-name"
:to="userProfileLink"
>
{{ status.user.screen_name }}
</router-link>
</div>
<span class="heading-right">
<router-link
class="timeago faint-link"
:to="{ name: 'conversation', params: { id: status.id } }"
>
<Timeago
:time="status.created_at"
:auto-update="60"
/>
</router-link>
<div
v-if="status.visibility"
class="button-icon visibility-icon"
>
<i
:class="visibilityIcon(status.visibility)"
:title="status.visibility | capitalize"
/>
</div>
<a
v-if="!status.is_local && !isPreview"
:href="status.external_url"
target="_blank"
class="source_url"
title="Source"
>
<i class="button-icon icon-link-ext-alt" />
</a>
<template v-if="expandable && !isPreview">
<a
href="#"
title="Expand"
@click.prevent="toggleExpanded"
>
<i class="button-icon icon-plus-squared" />
</a>
</template>
<a
v-if="unmuted"
href="#"
@click.prevent="toggleMute"
><i class="button-icon icon-eye-off" /></a>
</span>
</div>
<div class="heading-reply-row">
<div
v-if="isReply"
class="reply-to-and-accountname"
>
<StatusPopover
v-if="!isPreview"
:status-id="status.in_reply_to_status_id"
class="reply-to-popover"
style="min-width: 0"
>
<a
class="reply-to"
href="#"
:aria-label="$t('tool_tip.reply')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<i class="button-icon icon-reply" />
<span class="faint-link reply-to-text">{{ $t('status.reply_to') }}</span>
</a>
</StatusPopover>
<span
v-else
class="reply-to"
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
<router-link :to="replyProfileLink">
{{ replyToName }}
</router-link>
<span
v-if="replies && replies.length"
class="faint replies-separator"
>
-
</span>
</div>
<div
v-if="inConversation && !isPreview && replies && replies.length"
class="replies"
>
<span class="faint">{{ $t('status.replies_list') }}</span>
<StatusPopover
v-for="reply in replies"
:key="reply.id"
:status-id="reply.id"
>
<a
href="#"
class="reply-link"
@click.prevent="gotoOriginal(reply.id)"
>{{ reply.name }}</a>
</StatusPopover>
</div>
</div>
</div>
<StatusContent
:status="status"
:no-heading="noHeading"
:highlight="highlight"
:focused="isFocused"
/>
<transition name="fade">
<div
v-if="!hidePostStats && isFocused && combinedFavsAndRepeatsUsers.length > 0"
class="favs-repeated-users"
>
<div class="stats">
<div
v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0"
class="stat-count"
>
<a class="stat-title">{{ $t('status.repeats') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.rebloggedBy.length }}
</div>
</div>
<div
v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0"
class="stat-count"
>
<a class="stat-title">{{ $t('status.favorites') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.favoritedBy.length }}
</div>
</div>
<div class="avatar-row">
<AvatarList :users="combinedFavsAndRepeatsUsers" />
</div>
</div>
</div>
</transition>
<EmojiReactions
v-if="(mergedConfig.emojiReactionsOnTimeline || isFocused) && (!noHeading && !isPreview)"
:status="status"
/>
<div
v-if="!noHeading && !isPreview"
class="status-actions media-body"
>
<div>
<i
v-if="loggedIn"
class="button-icon icon-reply"
:title="$t('tool_tip.reply')"
:class="{'button-icon-active': replying}"
@click.prevent="toggleReplying"
/>
<i
v-else
class="button-icon button-icon-disabled icon-reply"
:title="$t('tool_tip.reply')"
/>
<span v-if="status.replies_count > 0">{{ status.replies_count }}</span>
</div>
<retweet-button
:visibility="status.visibility"
:logged-in="loggedIn"
:status="status"
/>
<favorite-button
:logged-in="loggedIn"
:status="status"
/>
<ReactButton
v-if="loggedIn"
:status="status"
/>
<extra-buttons
:status="status"
@onError="showError"
@onSuccess="clearError"
/>
</div>
</div>
</div>
<div
v-if="replying"
class="container"
>
<PostStatusForm
class="reply-body"
:reply-to="status.id"
:attentions="status.attentions"
:replied-user="status.user"
:copy-message-scope="status.visibility"
:subject="replySubject"
@posted="toggleReplying"
/>
</div>
</template>
</div>
<!-- eslint-enable vue/no-v-html -->
</template>
<script src="./status.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
$status-margin: 0.75em;
.status-body {
flex: 1;
min-width: 0;
}
.status-pin {
padding: $status-margin $status-margin 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
.media-left {
margin-right: $status-margin;
}
.status-el {
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
border-left-width: 0px;
min-width: 0;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
border-left: 4px $fallback--cRed;
border-left: 4px var(--cRed, $fallback--cRed);
&_focused {
background-color: $fallback--lightBg;
background-color: var(--selectedPost, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedPostText, $fallback--text);
--lightText: var(--selectedPostLightText, $fallback--light);
--faint: var(--selectedPostFaintText, $fallback--faint);
--faintLink: var(--selectedPostFaintLink, $fallback--faint);
--postLink: var(--selectedPostPostLink, $fallback--faint);
--postFaintLink: var(--selectedPostFaintPostLink, $fallback--faint);
--icon: var(--selectedPostIcon, $fallback--icon);
}
.timeline & {
border-bottom-width: 1px;
border-bottom-style: solid;
}
.media-body {
flex: 1;
padding: 0;
}
.status-usercard {
margin-bottom: $status-margin;
}
.user-name {
white-space: nowrap;
font-size: 14px;
overflow: hidden;
flex-shrink: 0;
max-width: 85%;
font-weight: bold;
img {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
.media-heading {
padding: 0;
vertical-align: bottom;
flex-basis: 100%;
margin-bottom: 0.5em;
small {
font-weight: lighter;
}
.heading-name-row {
padding: 0;
display: flex;
justify-content: space-between;
line-height: 18px;
a {
display: inline-block;
word-break: break-all;
}
.name-and-account-name {
display: flex;
min-width: 0;
}
.user-name {
flex-shrink: 1;
margin-right: 0.4em;
overflow: hidden;
text-overflow: ellipsis;
}
.account-name {
min-width: 1.6em;
margin-right: 0.4em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 0;
}
}
.heading-right {
display: flex;
flex-shrink: 0;
}
.timeago {
margin-right: 0.2em;
}
.heading-reply-row {
position: relative;
align-content: baseline;
font-size: 12px;
line-height: 18px;
max-width: 100%;
display: flex;
flex-wrap: wrap;
align-items: stretch;
> .reply-to-and-accountname > a {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-all;
}
}
.reply-to-and-accountname {
display: flex;
height: 18px;
margin-right: 0.5em;
max-width: 100%;
.icon-reply {
transform: scaleX(-1);
}
}
.reply-info {
display: flex;
}
.reply-to-popover {
min-width: 0;
}
.reply-to {
display: flex;
}
.reply-to-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0 0.4em 0 0.2em;
}
.replies-separator {
margin-left: 0.4em;
}
.replies {
line-height: 18px;
font-size: 12px;
display: flex;
flex-wrap: wrap;
& > * {
margin-right: 0.4em;
}
}
.reply-link {
height: 17px;
}
}
.retweet-info {
padding: 0.4em $status-margin;
margin: 0;
.avatar.still-image {
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
margin-left: 28px;
width: 20px;
height: 20px;
}
.media-body {
font-size: 1em;
line-height: 22px;
display: flex;
align-content: center;
flex-wrap: wrap;
.user-name {
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
img {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
i {
padding: 0 0.2em;
}
a {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
.status-fadein {
animation-duration: 0.4s;
animation-name: fadein;
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.status-conversation {
border-left-style: solid;
}
.status-actions {
position: relative;
width: 100%;
display: flex;
margin-top: $status-margin;
> * {
max-width: 4em;
flex: 1;
}
}
.button-icon.icon-reply {
&:not(.button-icon-disabled):hover,
&.button-icon-active {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
}
.button-icon.icon-reply {
&:not(.button-icon-disabled) {
cursor: pointer;
}
}
.status:hover .animated.avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
.status {
display: flex;
padding: $status-margin;
&.is-retweet {
padding-top: 0;
}
}
.status-conversation:last-child {
border-bottom: none;
}
.muted {
padding: 0.25em 0.5em;
button {
margin-left: auto;
}
.muteWords {
margin-left: 10px;
}
}
a.unmute {
display: block;
margin-left: auto;
}
.reply-body {
flex: 1;
}
.favs-repeated-users {
margin-top: $status-margin;
.stats {
width: 100%;
display: flex;
line-height: 1em;
.stat-count {
margin-right: $status-margin;
.stat-title {
color: var(--faint, $fallback--faint);
font-size: 12px;
text-transform: uppercase;
position: relative;
}
.stat-number {
font-weight: bolder;
font-size: 16px;
line-height: 1em;
}
}
.avatar-row {
flex: 1;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
&::before {
content: '';
position: absolute;
height: 100%;
width: 1px;
left: 0;
background-color: var(--faint, $fallback--faint);
}
}
}
}
@media all and (max-width: 800px) {
.status-el {
.retweet-info {
.avatar.still-image {
margin-left: 20px;
}
}
}
.status {
max-width: 100%;
}
.status .avatar.still-image {
width: 40px;
height: 40px;
&.avatar-compact {
width: 32px;
height: 32px;
}
}
}
</style>
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index 1cdbd3fa72..8e6b9d7fa5 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -1,146 +1,152 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import ProgressButton from '../progress_button/progress_button.vue'
import FollowButton from '../follow_button/follow_button.vue'
import ModerationTools from '../moderation_tools/moderation_tools.vue'
import AccountActions from '../account_actions/account_actions.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters } from 'vuex'
export default {
props: [
- 'user', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar'
+ 'userId', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar'
],
data () {
return {
followRequestInProgress: false,
betterShadow: this.$store.state.interface.browserSupport.cssFilter
}
},
created () {
this.$store.dispatch('fetchUserRelationship', this.user.id)
},
computed: {
+ user () {
+ return this.$store.getters.findUser(this.userId)
+ },
+ relationship () {
+ return this.$store.getters.relationship(this.userId)
+ },
classes () {
return [{
'user-card-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius
'user-card-rounded': this.rounded === true, // set border-radius for all sides
'user-card-bordered': this.bordered === true // set border for all sides
}]
},
style () {
return {
backgroundImage: [
`linear-gradient(to bottom, var(--profileTint), var(--profileTint))`,
`url(${this.user.cover_photo})`
].join(', ')
}
},
isOtherUser () {
return this.user.id !== this.$store.state.users.currentUser.id
},
subscribeUrl () {
// eslint-disable-next-line no-undef
const serverUrl = new URL(this.user.statusnet_profile_url)
return `${serverUrl.protocol}//${serverUrl.host}/main/ostatus`
},
loggedIn () {
return this.$store.state.users.currentUser
},
dailyAvg () {
const days = Math.ceil((new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000))
return Math.round(this.user.statuses_count / days)
},
userHighlightType: {
get () {
const data = this.$store.getters.mergedConfig.highlight[this.user.screen_name]
return (data && data.type) || 'disabled'
},
set (type) {
const data = this.$store.getters.mergedConfig.highlight[this.user.screen_name]
if (type !== 'disabled') {
this.$store.dispatch('setHighlight', { user: this.user.screen_name, color: (data && data.color) || '#FFFFFF', type })
} else {
this.$store.dispatch('setHighlight', { user: this.user.screen_name, color: undefined })
}
},
...mapGetters(['mergedConfig'])
},
userHighlightColor: {
get () {
const data = this.$store.getters.mergedConfig.highlight[this.user.screen_name]
return data && data.color
},
set (color) {
this.$store.dispatch('setHighlight', { user: this.user.screen_name, color })
}
},
visibleRole () {
const rights = this.user.rights
if (!rights) { return }
const validRole = rights.admin || rights.moderator
const roleTitle = rights.admin ? 'admin' : 'moderator'
return validRole && roleTitle
},
hideFollowsCount () {
return this.isOtherUser && this.user.hide_follows_count
},
hideFollowersCount () {
return this.isOtherUser && this.user.hide_followers_count
},
...mapGetters(['mergedConfig'])
},
components: {
UserAvatar,
RemoteFollow,
ModerationTools,
AccountActions,
ProgressButton,
FollowButton
},
methods: {
muteUser () {
this.$store.dispatch('muteUser', this.user.id)
},
unmuteUser () {
this.$store.dispatch('unmuteUser', this.user.id)
},
subscribeUser () {
return this.$store.dispatch('subscribeUser', this.user.id)
},
unsubscribeUser () {
return this.$store.dispatch('unsubscribeUser', this.user.id)
},
setProfileView (v) {
if (this.switcher) {
const store = this.$store
store.commit('setProfileView', { v })
}
},
linkClicked ({ target }) {
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
userProfileLink (user) {
return generateProfileLink(
user.id, user.screen_name,
this.$store.state.instance.restrictedNicknames
)
},
zoomAvatar () {
const attachment = {
url: this.user.profile_image_url_original,
mimetype: 'image'
}
this.$store.dispatch('setMedia', [attachment])
this.$store.dispatch('setCurrent', attachment)
},
mentionUser () {
this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })
}
}
}
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 4ee040e8be..b40435cd49 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -1,575 +1,576 @@
<template>
<div
class="user-card"
:class="classes"
>
<div
:class="{ 'hide-bio': hideBio }"
:style="style"
class="background-image"
/>
<div class="panel-heading">
<div class="user-info">
<div class="container">
<a
v-if="allowZoomingAvatar"
class="user-info-avatar-link"
@click="zoomAvatar"
>
<UserAvatar
:better-shadow="betterShadow"
:user="user"
/>
<div class="user-info-avatar-link-overlay">
<i class="button-icon icon-zoom-in" />
</div>
</a>
<router-link
v-else
:to="userProfileLink(user)"
>
<UserAvatar
:better-shadow="betterShadow"
:user="user"
/>
</router-link>
<div class="user-summary">
<div class="top-line">
<!-- eslint-disable vue/no-v-html -->
<div
v-if="user.name_html"
:title="user.name"
class="user-name"
v-html="user.name_html"
/>
<!-- eslint-enable vue/no-v-html -->
<div
v-else
:title="user.name"
class="user-name"
>
{{ user.name }}
</div>
<router-link
v-if="!isOtherUser"
:to="{ name: 'user-settings' }"
>
<i
class="button-icon icon-wrench usersettings"
:title="$t('tool_tip.user_settings')"
/>
</router-link>
<a
v-if="isOtherUser && !user.is_local"
:href="user.statusnet_profile_url"
target="_blank"
>
<i class="icon-link-ext usersettings" />
</a>
<AccountActions
v-if="isOtherUser && loggedIn"
:user="user"
+ :relationship="relationship"
/>
</div>
<div class="bottom-line">
<router-link
class="user-screen-name"
:to="userProfileLink(user)"
>
@{{ user.screen_name }}
</router-link>
<span
v-if="!hideBio && !!visibleRole"
class="alert staff"
>{{ visibleRole }}</span>
<span v-if="user.locked"><i class="icon icon-lock" /></span>
<span
v-if="!mergedConfig.hideUserStats && !hideBio"
class="dailyAvg"
>{{ dailyAvg }} {{ $t('user_card.per_day') }}</span>
</div>
</div>
</div>
<div class="user-meta">
<div
- v-if="user.follows_you && loggedIn && isOtherUser"
+ v-if="relationship.followed_by && loggedIn && isOtherUser"
class="following"
>
{{ $t('user_card.follows_you') }}
</div>
<div
v-if="isOtherUser && (loggedIn || !switcher)"
class="highlighter"
>
<!-- id's need to be unique, otherwise vue confuses which user-card checkbox belongs to -->
<input
v-if="userHighlightType !== 'disabled'"
:id="'userHighlightColorTx'+user.id"
v-model="userHighlightColor"
class="userHighlightText"
type="text"
>
<input
v-if="userHighlightType !== 'disabled'"
:id="'userHighlightColor'+user.id"
v-model="userHighlightColor"
class="userHighlightCl"
type="color"
>
<label
for="style-switcher"
class="userHighlightSel select"
>
<select
:id="'userHighlightSel'+user.id"
v-model="userHighlightType"
class="userHighlightSel"
>
<option value="disabled">No highlight</option>
<option value="solid">Solid bg</option>
<option value="striped">Striped bg</option>
<option value="side">Side stripe</option>
</select>
<i class="icon-down-open" />
</label>
</div>
</div>
<div
v-if="loggedIn && isOtherUser"
class="user-interactions"
>
<div class="btn-group">
- <FollowButton :user="user" />
- <template v-if="user.following">
+ <FollowButton :relationship="relationship" />
+ <template v-if="relationship.following">
<ProgressButton
- v-if="!user.subscribed"
+ v-if="!relationship.subscribing"
class="btn btn-default"
:click="subscribeUser"
:title="$t('user_card.subscribe')"
>
<i class="icon-bell-alt" />
</ProgressButton>
<ProgressButton
v-else
class="btn btn-default toggled"
:click="unsubscribeUser"
:title="$t('user_card.unsubscribe')"
>
<i class="icon-bell-ringing-o" />
</ProgressButton>
</template>
</div>
<div>
<button
- v-if="user.muted"
+ v-if="relationship.muting"
class="btn btn-default btn-block toggled"
@click="unmuteUser"
>
{{ $t('user_card.muted') }}
</button>
<button
v-else
class="btn btn-default btn-block"
@click="muteUser"
>
{{ $t('user_card.mute') }}
</button>
</div>
<div>
<button
class="btn btn-default btn-block"
@click="mentionUser"
>
{{ $t('user_card.mention') }}
</button>
</div>
<ModerationTools
v-if="loggedIn.role === &quot;admin&quot;"
:user="user"
/>
</div>
<div
v-if="!loggedIn && user.is_local"
class="user-interactions"
>
<RemoteFollow :user="user" />
</div>
</div>
</div>
<div
v-if="!hideBio"
class="panel-body"
>
<div
v-if="!mergedConfig.hideUserStats && switcher"
class="user-counts"
>
<div
class="user-count"
@click.prevent="setProfileView('statuses')"
>
<h5>{{ $t('user_card.statuses') }}</h5>
<span>{{ user.statuses_count }} <br></span>
</div>
<div
class="user-count"
@click.prevent="setProfileView('friends')"
>
<h5>{{ $t('user_card.followees') }}</h5>
<span>{{ hideFollowsCount ? $t('user_card.hidden') : user.friends_count }}</span>
</div>
<div
class="user-count"
@click.prevent="setProfileView('followers')"
>
<h5>{{ $t('user_card.followers') }}</h5>
<span>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</span>
</div>
</div>
<!-- eslint-disable vue/no-v-html -->
<p
v-if="!hideBio && user.description_html"
class="user-card-bio"
@click.prevent="linkClicked"
v-html="user.description_html"
/>
<!-- eslint-enable vue/no-v-html -->
<p
v-else-if="!hideBio"
class="user-card-bio"
>
{{ user.description }}
</p>
</div>
</div>
</template>
<script src="./user_card.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.user-card {
position: relative;
.panel-heading {
padding: .5em 0;
text-align: center;
box-shadow: none;
background: transparent;
flex-direction: column;
align-items: stretch;
// create new stacking context
position: relative;
}
.panel-body {
word-wrap: break-word;
border-bottom-right-radius: inherit;
border-bottom-left-radius: inherit;
// create new stacking context
position: relative;
}
.background-image {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
mask: linear-gradient(to top, white, transparent) bottom no-repeat,
linear-gradient(to top, white, white);
// Autoprefixed seem to ignore this one, and also syntax is different
-webkit-mask-composite: xor;
mask-composite: exclude;
background-size: cover;
mask-size: 100% 60%;
border-top-left-radius: calc(var(--panelRadius) - 1px);
border-top-right-radius: calc(var(--panelRadius) - 1px);
background-color: var(--profileBg);
&.hide-bio {
mask-size: 100% 40px;
}
}
p {
margin-bottom: 0;
}
&-bio {
text-align: center;
a {
color: $fallback--link;
color: var(--postLink, $fallback--link);
}
img {
object-fit: contain;
vertical-align: middle;
max-width: 100%;
max-height: 400px;
&.emoji {
width: 32px;
height: 32px;
}
}
}
// Modifiers
&-rounded-t {
border-top-left-radius: $fallback--panelRadius;
border-top-left-radius: var(--panelRadius, $fallback--panelRadius);
border-top-right-radius: $fallback--panelRadius;
border-top-right-radius: var(--panelRadius, $fallback--panelRadius);
}
&-rounded {
border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius);
}
&-bordered {
border-width: 1px;
border-style: solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
}
}
.user-info {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
padding: 0 26px;
.container {
padding: 16px 0 6px;
display: flex;
align-items: flex-start;
max-height: 56px;
.avatar {
flex: 1 0 100%;
width: 56px;
height: 56px;
box-shadow: 0px 1px 8px rgba(0,0,0,0.75);
box-shadow: var(--avatarShadow);
object-fit: cover;
}
}
&:hover .animated.avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
&-avatar-link {
position: relative;
cursor: pointer;
&-overlay {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: center;
align-items: center;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
opacity: 0;
transition: opacity .2s ease;
i {
color: #FFF;
}
}
&:hover &-overlay {
opacity: 1;
}
}
.usersettings {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
opacity: .8;
}
.user-summary {
display: block;
margin-left: 0.6em;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 0;
// This is so that text doesn't get overlapped by avatar's shadow if it has
// big one
z-index: 1;
img {
width: 26px;
height: 26px;
vertical-align: middle;
object-fit: contain
}
.top-line {
display: flex;
}
}
.user-name {
text-overflow: ellipsis;
overflow: hidden;
flex: 1 1 auto;
margin-right: 1em;
font-size: 15px;
img {
object-fit: contain;
height: 16px;
width: 16px;
vertical-align: middle;
}
}
.bottom-line {
display: flex;
font-weight: light;
font-size: 15px;
.user-screen-name {
min-width: 1px;
flex: 0 1 auto;
text-overflow: ellipsis;
overflow: hidden;
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
.dailyAvg {
min-width: 1px;
flex: 0 0 auto;
margin-left: 1em;
font-size: 0.7em;
color: $fallback--text;
color: var(--text, $fallback--text);
}
.staff {
flex: none;
text-transform: capitalize;
color: $fallback--text;
color: var(--alertNeutralText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--alertNeutral, $fallback--fg);
}
}
.user-meta {
margin-bottom: .15em;
display: flex;
align-items: baseline;
font-size: 14px;
line-height: 22px;
flex-wrap: wrap;
.following {
flex: 1 0 auto;
margin: 0;
margin-bottom: .25em;
text-align: left;
}
.highlighter {
flex: 0 1 auto;
display: flex;
flex-wrap: wrap;
margin-right: -.5em;
align-self: start;
.userHighlightCl {
padding: 2px 10px;
flex: 1 0 auto;
}
.userHighlightSel,
.userHighlightSel.select {
padding-top: 0;
padding-bottom: 0;
flex: 1 0 auto;
}
.userHighlightSel.select i {
line-height: 22px;
}
.userHighlightText {
width: 70px;
flex: 1 0 auto;
}
.userHighlightCl,
.userHighlightText,
.userHighlightSel,
.userHighlightSel.select {
height: 22px;
vertical-align: top;
margin-right: .5em;
margin-bottom: .25em;
}
}
}
.user-interactions {
position: relative;
display: flex;
flex-flow: row wrap;
margin-right: -.75em;
> * {
margin: 0 .75em .6em 0;
white-space: nowrap;
min-width: 95px;
}
button {
margin: 0;
}
}
}
.user-counts {
display: flex;
line-height:16px;
padding: .5em 1.5em 0em 1.5em;
text-align: center;
justify-content: space-between;
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
flex-wrap: wrap;
}
.user-count {
flex: 1 0 auto;
padding: .5em 0 .5em 0;
margin: 0 .5em;
h5 {
font-size:1em;
font-weight: bolder;
margin: 0 0 0.25em;
}
a {
text-decoration: none;
}
}
</style>
diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue
index e9f08015a1..1db4f64881 100644
--- a/src/components/user_panel/user_panel.vue
+++ b/src/components/user_panel/user_panel.vue
@@ -1,30 +1,30 @@
<template>
<div class="user-panel">
<div
v-if="signedIn"
key="user-panel"
class="panel panel-default signed-in"
>
<UserCard
- :user="user"
+ :user-id="user.id"
:hide-bio="true"
rounded="top"
/>
<div class="panel-footer">
<PostStatusForm />
</div>
</div>
<auth-form
v-else
key="user-panel"
/>
</div>
</template>
<script src="./user_panel.js"></script>
<style lang="scss">
.user-panel .signed-in {
overflow: visible;
}
</style>
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 14082e8394..1871d46c62 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -1,149 +1,149 @@
<template>
<div>
<div
v-if="user"
class="user-profile panel panel-default"
>
<UserCard
- :user="user"
+ :user-id="userId"
:switcher="true"
:selected="timeline.viewing"
:allow-zooming-avatar="true"
rounded="top"
/>
<tab-switcher
:active-tab="tab"
:render-only-focused="true"
:on-switch="onTabSwitch"
>
<Timeline
key="statuses"
:label="$t('user_card.statuses')"
:count="user.statuses_count"
:embedded="true"
:title="$t('user_profile.timeline_title')"
:timeline="timeline"
timeline-name="user"
:user-id="userId"
:pinned-status-ids="user.pinnedStatusIds"
:in-profile="true"
/>
<div
v-if="followsTabVisible"
key="followees"
:label="$t('user_card.followees')"
:disabled="!user.friends_count"
>
<FriendList :user-id="userId">
<template
slot="item"
slot-scope="{item}"
>
<FollowCard :user="item" />
</template>
</FriendList>
</div>
<div
v-if="followersTabVisible"
key="followers"
:label="$t('user_card.followers')"
:disabled="!user.followers_count"
>
<FollowerList :user-id="userId">
<template
slot="item"
slot-scope="{item}"
>
<FollowCard
:user="item"
:no-follows-you="isUs"
/>
</template>
</FollowerList>
</div>
<Timeline
key="media"
:label="$t('user_card.media')"
:disabled="!media.visibleStatuses.length"
:embedded="true"
:title="$t('user_card.media')"
timeline-name="media"
:timeline="media"
:user-id="userId"
:in-profile="true"
/>
<Timeline
v-if="isUs"
key="favorites"
:label="$t('user_card.favorites')"
:disabled="!favorites.visibleStatuses.length"
:embedded="true"
:title="$t('user_card.favorites')"
timeline-name="favorites"
:timeline="favorites"
:in-profile="true"
/>
</tab-switcher>
</div>
<div
v-else
class="panel user-profile-placeholder"
>
<div class="panel-heading">
<div class="title">
{{ $t('settings.profile_tab') }}
</div>
</div>
<div class="panel-body">
<span v-if="error">{{ error }}</span>
<i
v-else
class="icon-spin3 animate-spin"
/>
</div>
</div>
</div>
</template>
<script src="./user_profile.js"></script>
<style lang="scss">
.user-profile {
flex: 2;
flex-basis: 500px;
.userlist-placeholder {
display: flex;
justify-content: center;
align-items: middle;
padding: 2em;
}
.timeline-heading {
display: flex;
justify-content: center;
.loadmore-button, .alert {
flex: 1;
}
.loadmore-button {
height: 28px;
margin: 10px .6em;
}
.title, .loadmore-text {
display: none
}
}
}
.user-profile-placeholder {
.panel-body {
display: flex;
justify-content: center;
align-items: middle;
padding: 7em;
}
}
</style>
diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js
index eca6f9b16f..5338c97402 100644
--- a/src/components/user_settings/user_settings.js
+++ b/src/components/user_settings/user_settings.js
@@ -1,393 +1,393 @@
import unescape from 'lodash/unescape'
import get from 'lodash/get'
import map from 'lodash/map'
import reject from 'lodash/reject'
import TabSwitcher from '../tab_switcher/tab_switcher.js'
import ImageCropper from '../image_cropper/image_cropper.vue'
import StyleSwitcher from '../style_switcher/style_switcher.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
import BlockCard from '../block_card/block_card.vue'
import MuteCard from '../mute_card/mute_card.vue'
import DomainMuteCard from '../domain_mute_card/domain_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 suggestor from '../emoji_input/suggestor.js'
import Autosuggest from '../autosuggest/autosuggest.vue'
import Importer from '../importer/importer.vue'
import Exporter from '../exporter/exporter.vue'
import withSubscription from '../../hocs/with_subscription/with_subscription'
import Checkbox from '../checkbox/checkbox.vue'
import Mfa from './mfa.vue'
const BlockList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchBlocks'),
select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []),
childPropName: 'items'
})(SelectableList)
const MuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []),
childPropName: 'items'
})(SelectableList)
const DomainMuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchDomainMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'domainMutes', []),
childPropName: 'items'
})(SelectableList)
const UserSettings = {
data () {
return {
newEmail: '',
newName: this.$store.state.users.currentUser.name,
newBio: unescape(this.$store.state.users.currentUser.description),
newLocked: this.$store.state.users.currentUser.locked,
newNoRichText: this.$store.state.users.currentUser.no_rich_text,
newDefaultScope: this.$store.state.users.currentUser.default_scope,
hideFollows: this.$store.state.users.currentUser.hide_follows,
hideFollowers: this.$store.state.users.currentUser.hide_followers,
hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count,
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
discoverable: this.$store.state.users.currentUser.discoverable,
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true,
bannerUploading: false,
backgroundUploading: false,
banner: null,
bannerPreview: null,
background: null,
backgroundPreview: null,
bannerUploadError: null,
backgroundUploadError: null,
changeEmailError: false,
changeEmailPassword: '',
changedEmail: false,
deletingAccount: false,
deleteAccountConfirmPasswordInput: '',
deleteAccountError: false,
changePasswordInputs: [ '', '', '' ],
changedPassword: false,
changePasswordError: false,
activeTab: 'profile',
notificationSettings: this.$store.state.users.currentUser.notification_settings,
newDomainToMute: ''
}
},
created () {
this.$store.dispatch('fetchTokens')
},
components: {
StyleSwitcher,
ScopeSelector,
TabSwitcher,
ImageCropper,
BlockList,
MuteList,
DomainMuteList,
EmojiInput,
Autosuggest,
BlockCard,
MuteCard,
DomainMuteCard,
ProgressButton,
Importer,
Exporter,
Mfa,
Checkbox
},
computed: {
user () {
return this.$store.state.users.currentUser
},
emojiUserSuggestor () {
return suggestor({
emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
],
users: this.$store.state.users.users,
updateUsersList: (input) => this.$store.dispatch('searchUsers', input)
})
},
emojiSuggestor () {
return suggestor({ emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
] })
},
pleromaBackend () {
return this.$store.state.instance.pleromaBackend
},
minimalScopesMode () {
return this.$store.state.instance.minimalScopesMode
},
vis () {
return {
public: { selected: this.newDefaultScope === 'public' },
unlisted: { selected: this.newDefaultScope === 'unlisted' },
private: { selected: this.newDefaultScope === 'private' },
direct: { selected: this.newDefaultScope === 'direct' }
}
},
currentSaveStateNotice () {
return this.$store.state.interface.settings.currentSaveStateNotice
},
oauthTokens () {
return this.$store.state.oauthTokens.tokens.map(oauthToken => {
return {
id: oauthToken.id,
appName: oauthToken.app_name,
validUntil: new Date(oauthToken.valid_until).toLocaleDateString()
}
})
}
},
methods: {
updateProfile () {
this.$store.state.api.backendInteractor
.updateProfile({
params: {
note: this.newBio,
locked: this.newLocked,
// Backend notation.
/* eslint-disable camelcase */
display_name: this.newName,
default_scope: this.newDefaultScope,
no_rich_text: this.newNoRichText,
hide_follows: this.hideFollows,
hide_followers: this.hideFollowers,
discoverable: this.discoverable,
allow_following_move: this.allowFollowingMove,
hide_follows_count: this.hideFollowsCount,
hide_followers_count: this.hideFollowersCount,
show_role: this.showRole
/* eslint-enable camelcase */
} }).then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
updateNotificationSettings () {
this.$store.state.api.backendInteractor
.updateNotificationSettings({ settings: this.notificationSettings })
},
changeVis (visibility) {
this.newDefaultScope = visibility
},
uploadFile (slot, e) {
const file = e.target.files[0]
if (!file) { return }
if (file.size > this.$store.state.instance[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
this[slot + 'UploadError'] = this.$t('upload.error.base') + ' ' + this.$t('upload.error.file_too_big', { filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit })
return
}
// eslint-disable-next-line no-undef
const reader = new FileReader()
reader.onload = ({ target }) => {
const img = target.result
this[slot + 'Preview'] = img
this[slot] = file
}
reader.readAsDataURL(file)
},
submitAvatar (cropper, file) {
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar) {
that.$store.state.api.backendInteractor.updateAvatar({ avatar })
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
resolve()
})
.catch((err) => {
reject(new Error(that.$t('upload.error.base') + ' ' + err.message))
})
}
if (cropper) {
cropper.getCroppedCanvas().toBlob(updateAvatar, file.type)
} else {
updateAvatar(file)
}
})
},
clearUploadError (slot) {
this[slot + 'UploadError'] = null
},
submitBanner () {
if (!this.bannerPreview) { return }
this.bannerUploading = true
this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.bannerPreview = null
})
.catch((err) => {
this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message
})
.then(() => { this.bannerUploading = false })
},
submitBg () {
if (!this.backgroundPreview) { return }
let background = this.background
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => {
if (!data.error) {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
} else {
this.backgroundUploadError = this.$t('upload.error.base') + data.error
}
this.backgroundUploading = false
})
},
importFollows (file) {
return this.$store.state.api.backendInteractor.importFollows({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
importBlocks (file) {
return this.$store.state.api.backendInteractor.importBlocks({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
generateExportableUsersContent (users) {
// Get addresses
return users.map((user) => {
// check is it's a local user
if (user && user.is_local) {
// append the instance address
// eslint-disable-next-line no-undef
return user.screen_name + '@' + location.hostname
}
return user.screen_name
}).join('\n')
},
getFollowsContent () {
return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id })
.then(this.generateExportableUsersContent)
},
getBlocksContent () {
return this.$store.state.api.backendInteractor.fetchBlocks()
.then(this.generateExportableUsersContent)
},
confirmDelete () {
this.deletingAccount = true
},
deleteAccount () {
this.$store.state.api.backendInteractor.deleteAccount({ password: this.deleteAccountConfirmPasswordInput })
.then((res) => {
if (res.status === 'success') {
this.$store.dispatch('logout')
this.$router.push({ name: 'root' })
} else {
this.deleteAccountError = res.error
}
})
},
changePassword () {
const params = {
password: this.changePasswordInputs[0],
newPassword: this.changePasswordInputs[1],
newPasswordConfirmation: this.changePasswordInputs[2]
}
this.$store.state.api.backendInteractor.changePassword(params)
.then((res) => {
if (res.status === 'success') {
this.changedPassword = true
this.changePasswordError = false
this.logout()
} else {
this.changedPassword = false
this.changePasswordError = res.error
}
})
},
changeEmail () {
const params = {
email: this.newEmail,
password: this.changeEmailPassword
}
this.$store.state.api.backendInteractor.changeEmail(params)
.then((res) => {
if (res.status === 'success') {
this.changedEmail = true
this.changeEmailError = false
} else {
this.changedEmail = false
this.changeEmailError = res.error
}
})
},
activateTab (tabName) {
this.activeTab = tabName
},
logout () {
this.$store.dispatch('logout')
this.$router.replace('/')
},
revokeToken (id) {
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
+ const relationship = this.$store.getters.relationship(this.userId)
+ return relationship.blocking || userId === 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
+ const relationship = this.$store.getters.relationship(this.userId)
+ return relationship.muting || userId === this.$store.state.users.currentUser.id
})
},
queryUserIds (query) {
return this.$store.dispatch('searchUsers', query)
.then((users) => 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)
},
unmuteDomains (domains) {
return this.$store.dispatch('unmuteDomains', domains)
},
muteDomain () {
return this.$store.dispatch('muteDomain', this.newDomainToMute)
.then(() => { this.newDomainToMute = '' })
},
identity (value) {
return value
}
}
}
export default UserSettings
diff --git a/src/modules/users.js b/src/modules/users.js
index df133be089..1d1b415c8b 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -1,573 +1,570 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import oauthApi from '../services/new_api/oauth.js'
import { compact, map, each, merge, last, concat, uniq } from 'lodash'
import { set } from 'vue'
import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js'
// TODO: Unify with mergeOrAdd in statuses.js
export const mergeOrAdd = (arr, obj, item) => {
if (!item) { return false }
const oldItem = obj[item.id]
if (oldItem) {
// We already have this, so only merge the new info.
merge(oldItem, item)
return { item: oldItem, new: false }
} else {
// This is a new item, prepare it
arr.push(item)
set(obj, item.id, item)
if (item.screen_name && !item.screen_name.includes('@')) {
set(obj, item.screen_name.toLowerCase(), item)
}
return { item, new: true }
}
}
const getNotificationPermission = () => {
const Notification = window.Notification
if (!Notification) return Promise.resolve(null)
if (Notification.permission === 'default') return Notification.requestPermission()
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) => {
+ const predictedRelationship = store.state.relationships[id] || { id }
+ predictedRelationship.muting = true
+ store.commit('updateUserRelationship', [predictedRelationship])
+ store.commit('addMuteId', id)
+
return store.rootState.api.backendInteractor.muteUser({ id })
.then((relationship) => {
store.commit('updateUserRelationship', [relationship])
store.commit('addMuteId', id)
})
}
const unmuteUser = (store, id) => {
+ const predictedRelationship = store.state.relationships[id] || { id }
+ predictedRelationship.muting = false
+ store.commit('updateUserRelationship', [predictedRelationship])
+
return store.rootState.api.backendInteractor.unmuteUser({ id })
.then((relationship) => store.commit('updateUserRelationship', [relationship]))
}
const hideReblogs = (store, userId) => {
return store.rootState.api.backendInteractor.followUser({ id: userId, reblogs: false })
.then((relationship) => {
store.commit('updateUserRelationship', [relationship])
})
}
const showReblogs = (store, userId) => {
return store.rootState.api.backendInteractor.followUser({ id: userId, reblogs: true })
.then((relationship) => store.commit('updateUserRelationship', [relationship]))
}
const muteDomain = (store, domain) => {
return store.rootState.api.backendInteractor.muteDomain({ domain })
.then(() => store.commit('addDomainMute', domain))
}
const unmuteDomain = (store, domain) => {
return store.rootState.api.backendInteractor.unmuteDomain({ domain })
.then(() => store.commit('removeDomainMute', domain))
}
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 }, deactivated }) {
const user = state.usersObject[id]
set(user, 'deactivated', deactivated)
},
setCurrentUser (state, user) {
state.lastLoginName = user.screen_name
state.currentUser = merge(state.currentUser || {}, user)
},
clearCurrentUser (state) {
state.currentUser = false
state.lastLoginName = false
},
beginLogin (state) {
state.loggingIn = true
},
endLogin (state) {
state.loggingIn = false
},
saveFriendIds (state, { id, friendIds }) {
const user = state.usersObject[id]
user.friendIds = uniq(concat(user.friendIds, friendIds))
},
saveFollowerIds (state, { id, followerIds }) {
const user = state.usersObject[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) {
set(user, 'friendIds', [])
}
},
clearFollowers (state, userId) {
const user = state.usersObject[userId]
if (user) {
set(user, 'followerIds', [])
}
},
addNewUsers (state, users) {
- each(users, (user) => mergeOrAdd(state.users, state.usersObject, user))
+ each(users, (user) => {
+ if (user.relationship) {
+ set(state.relationships, user.relationship.id, user.relationship)
+ }
+ mergeOrAdd(state.users, state.usersObject, user)
+ })
},
updateUserRelationship (state, relationships) {
relationships.forEach((relationship) => {
- const user = state.usersObject[relationship.id]
- if (user) {
- user.follows_you = relationship.followed_by
- user.following = relationship.following
- user.muted = relationship.muting
- user.statusnet_blocking = relationship.blocking
- user.subscribed = relationship.subscribing
- user.showing_reblogs = relationship.showing_reblogs
- }
+ set(state.relationships, relationship.id, relationship)
})
},
- updateBlocks (state, blockedUsers) {
- // Reset statusnet_blocking of all fetched users
- each(state.users, (user) => { user.statusnet_blocking = false })
- each(blockedUsers, (user) => mergeOrAdd(state.users, state.usersObject, user))
- },
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 })
- each(mutedUsers, (user) => mergeOrAdd(state.users, state.usersObject, user))
- },
saveMuteIds (state, muteIds) {
state.currentUser.muteIds = muteIds
},
addMuteId (state, muteId) {
if (state.currentUser.muteIds.indexOf(muteId) === -1) {
state.currentUser.muteIds.push(muteId)
}
},
saveDomainMutes (state, domainMutes) {
state.currentUser.domainMutes = domainMutes
},
addDomainMute (state, domain) {
if (state.currentUser.domainMutes.indexOf(domain) === -1) {
state.currentUser.domainMutes.push(domain)
}
},
removeDomainMute (state, domain) {
const index = state.currentUser.domainMutes.indexOf(domain)
if (index !== -1) {
state.currentUser.domainMutes.splice(index, 1)
}
},
setPinnedToUser (state, status) {
const user = state.usersObject[status.user.id]
const index = user.pinnedStatusIds.indexOf(status.id)
if (status.pinned && index === -1) {
user.pinnedStatusIds.push(status.id)
} else if (!status.pinned && index !== -1) {
user.pinnedStatusIds.splice(index, 1)
}
},
setUserForStatus (state, status) {
status.user = state.usersObject[status.user.id]
},
setUserForNotification (state, notification) {
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]
set(user, 'highlight', highlighted)
},
signUpPending (state) {
state.signUpPending = true
state.signUpErrors = []
},
signUpSuccess (state) {
state.signUpPending = false
},
signUpFailure (state, errors) {
state.signUpPending = false
state.signUpErrors = errors
}
}
export const getters = {
findUser: state => query => {
const result = state.usersObject[query]
// In case it's a screen_name, we can try searching case-insensitive
if (!result && typeof query === 'string') {
return state.usersObject[query.toLowerCase()]
}
return result
+ },
+ relationship: state => id => {
+ const rel = id && state.relationships[id]
+ return rel || { id, loading: true }
}
}
export const defaultState = {
loggingIn: false,
lastLoginName: false,
currentUser: false,
users: [],
usersObject: {},
signUpPending: false,
- signUpErrors: []
+ signUpErrors: [],
+ relationships: {}
}
const users = {
state: defaultState,
mutations,
getters,
actions: {
fetchUser (store, id) {
return store.rootState.api.backendInteractor.fetchUser({ id })
.then((user) => {
store.commit('addNewUsers', [user])
return user
})
},
fetchUserRelationship (store, id) {
if (store.state.currentUser) {
store.rootState.api.backendInteractor.fetchUserRelationship({ id })
.then((relationships) => store.commit('updateUserRelationship', relationships))
}
},
fetchBlocks (store) {
return store.rootState.api.backendInteractor.fetchBlocks()
.then((blocks) => {
store.commit('saveBlockIds', map(blocks, 'id'))
- store.commit('updateBlocks', blocks)
+ store.commit('addNewUsers', blocks)
return blocks
})
},
blockUser (store, id) {
return blockUser(store, id)
},
unblockUser (store, id) {
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()
.then((mutes) => {
- store.commit('updateMutes', mutes)
store.commit('saveMuteIds', map(mutes, 'id'))
+ store.commit('addNewUsers', mutes)
return mutes
})
},
muteUser (store, id) {
return muteUser(store, id)
},
unmuteUser (store, id) {
return unmuteUser(store, id)
},
hideReblogs (store, id) {
return hideReblogs(store, id)
},
showReblogs (store, id) {
return showReblogs(store, id)
},
muteUsers (store, ids = []) {
return Promise.all(ids.map(id => muteUser(store, id)))
},
unmuteUsers (store, ids = []) {
return Promise.all(ids.map(id => unmuteUser(store, id)))
},
fetchDomainMutes (store) {
return store.rootState.api.backendInteractor.fetchDomainMutes()
.then((domainMutes) => {
store.commit('saveDomainMutes', domainMutes)
return domainMutes
})
},
muteDomain (store, domain) {
return muteDomain(store, domain)
},
unmuteDomain (store, domain) {
return unmuteDomain(store, domain)
},
muteDomains (store, domains = []) {
return Promise.all(domains.map(domain => muteDomain(store, domain)))
},
unmuteDomains (store, domain = []) {
return Promise.all(domain.map(domain => unmuteDomain(store, domain)))
},
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('addNewUsers', followers)
commit('saveFollowerIds', { id, followerIds: map(followers, 'id') })
return followers
})
},
clearFriends ({ commit }, userId) {
commit('clearFriends', userId)
},
clearFollowers ({ commit }, userId) {
commit('clearFollowers', userId)
},
subscribeUser ({ rootState, commit }, id) {
return rootState.api.backendInteractor.subscribeUser({ id })
.then((relationship) => commit('updateUserRelationship', [relationship]))
},
unsubscribeUser ({ rootState, commit }, id) {
return rootState.api.backendInteractor.unsubscribeUser({ id })
.then((relationship) => commit('updateUserRelationship', [relationship]))
},
toggleActivationStatus ({ rootState, commit }, { user }) {
const api = user.deactivated ? rootState.api.backendInteractor.activateUser : rootState.api.backendInteractor.deactivateUser
api({ user })
.then(({ deactivated }) => commit('updateActivationStatus', { user, deactivated }))
},
registerPushNotifications (store) {
const token = store.state.currentUser.credentials
const vapidPublicKey = store.rootState.instance.vapidPublicKey
const isEnabled = store.rootState.config.webPushNotifications
const notificationVisibility = store.rootState.config.notificationVisibility
registerPushNotifications(isEnabled, vapidPublicKey, token, notificationVisibility)
},
unregisterPushNotifications (store) {
const token = store.state.currentUser.credentials
unregisterPushNotifications(token)
},
addNewUsers ({ commit }, users) {
commit('addNewUsers', users)
},
addNewStatuses (store, { statuses }) {
const users = map(statuses, 'user')
const retweetedUsers = compact(map(statuses, 'retweeted_status.user'))
store.commit('addNewUsers', users)
store.commit('addNewUsers', retweetedUsers)
each(statuses, (status) => {
// Reconnect users to statuses
store.commit('setUserForStatus', status)
// Set pinned statuses to user
store.commit('setPinnedToUser', status)
})
each(compact(map(statuses, 'retweeted_status')), (status) => {
// Reconnect users to retweets
store.commit('setUserForStatus', status)
// Set pinned retweets to user
store.commit('setPinnedToUser', status)
})
},
addNewNotifications (store, { notifications }) {
const users = map(notifications, 'from_profile')
- const targetUsers = map(notifications, 'target')
+ const targetUsers = map(notifications, 'target').filter(_ => _)
const notificationIds = notifications.map(_ => _.id)
store.commit('addNewUsers', users)
store.commit('addNewUsers', targetUsers)
const notificationsObject = store.rootState.statuses.notifications.idStore
const relevantNotifications = Object.entries(notificationsObject)
.filter(([k, val]) => notificationIds.includes(k))
.map(([k, val]) => val)
// Reconnect users to notifications
each(relevantNotifications, (notification) => {
store.commit('setUserForNotification', notification)
})
},
searchUsers (store, query) {
return store.rootState.api.backendInteractor.searchUsers({ query })
.then((users) => {
store.commit('addNewUsers', users)
return users
})
},
async signUp (store, userInfo) {
store.commit('signUpPending')
let rootState = store.rootState
try {
let data = await rootState.api.backendInteractor.register(
{ params: { ...userInfo } }
)
store.commit('signUpSuccess')
store.commit('setToken', data.access_token)
store.dispatch('loginUser', data.access_token)
} catch (e) {
let errors = e.message
store.commit('signUpFailure', errors)
throw e
}
},
async getCaptcha (store) {
return store.rootState.api.backendInteractor.getCaptcha()
},
logout (store) {
const { oauth, instance } = store.rootState
const data = {
...oauth,
commit: store.commit,
instance: instance.server
}
return oauthApi.getOrCreateApp(data)
.then((app) => {
const params = {
app,
instance: data.instance,
token: oauth.userToken
}
return oauthApi.revokeToken(params)
})
.then(() => {
store.commit('clearCurrentUser')
store.dispatch('disconnectFromSocket')
store.commit('clearToken')
store.dispatch('stopFetchingTimeline', 'friends')
store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
store.dispatch('stopFetchingNotifications')
store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications')
store.commit('resetStatuses')
})
},
loginUser (store, accessToken) {
return new Promise((resolve, reject) => {
const commit = store.commit
commit('beginLogin')
store.rootState.api.backendInteractor.verifyCredentials(accessToken)
.then((data) => {
if (!data.error) {
const user = data
// user.credentials = userCredentials
user.credentials = accessToken
user.blockIds = []
user.muteIds = []
user.domainMutes = []
commit('setCurrentUser', user)
commit('addNewUsers', [user])
store.dispatch('fetchEmoji')
getNotificationPermission()
.then(permission => commit('setNotificationPermission', permission))
// Set our new backend interactor
commit('setBackendInteractor', backendInteractorService(accessToken))
if (user.token) {
store.dispatch('setWsToken', user.token)
// Initialize the chat socket.
store.dispatch('initializeSocket')
}
const startPolling = () => {
// Start getting fresh posts.
store.dispatch('startFetchingTimeline', { timeline: 'friends' })
// Start fetching notifications
store.dispatch('startFetchingNotifications')
}
if (store.getters.mergedConfig.useStreamingApi) {
store.dispatch('enableMastoSockets').catch((error) => {
console.error('Failed initializing MastoAPI Streaming socket', error)
startPolling()
}).then(() => {
setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000)
})
} else {
startPolling()
}
// Get user mutes
store.dispatch('fetchMutes')
// Fetch our friends
store.rootState.api.backendInteractor.fetchFriends({ id: user.id })
.then((friends) => commit('addNewUsers', friends))
} else {
const response = data.error
// Authentication failed
commit('endLogin')
if (response.status === 401) {
reject(new Error('Wrong username or password'))
} else {
reject(new Error('An error occurred, please try again'))
}
}
commit('endLogin')
resolve()
})
.catch((error) => {
console.log(error)
commit('endLogin')
reject(new Error('Failed to connect to server, try again'))
})
})
}
}
}
export default users
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 6cacd0b825..6dac7c1570 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -1,377 +1,371 @@
import escape from 'escape-html'
import { isStatusNotification } from '../notification_utils/notification_utils.js'
const qvitterStatusType = (status) => {
if (status.is_post_verb) {
return 'status'
}
if (status.retweeted_status) {
return 'retweet'
}
if ((typeof status.uri === 'string' && status.uri.match(/(fave|objectType=Favourite)/)) ||
(typeof status.text === 'string' && status.text.match(/favorited/))) {
return 'favorite'
}
if (status.text.match(/deleted notice {{tag/) || status.qvitter_delete_notice) {
return 'deletion'
}
if (status.text.match(/started following/) || status.activity_type === 'follow') {
return 'follow'
}
return 'unknown'
}
export const parseUser = (data) => {
const output = {}
const masto = data.hasOwnProperty('acct')
// case for users in "mentions" property for statuses in MastoAPI
const mastoShort = masto && !data.hasOwnProperty('avatar')
output.id = String(data.id)
if (masto) {
output.screen_name = data.acct
output.statusnet_profile_url = data.url
// There's nothing else to get
if (mastoShort) {
return output
}
output.name = data.display_name
output.name_html = addEmojis(escape(data.display_name), data.emojis)
output.description = data.note
output.description_html = addEmojis(data.note, data.emojis)
output.fields = data.fields
output.fields_html = data.fields.map(field => {
return {
name: addEmojis(field.name, data.emojis),
value: addEmojis(field.value, data.emojis)
}
})
// Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar
output.profile_image_url_original = data.avatar
// Same, utilize header_static?
output.cover_photo = data.header
output.friends_count = data.following_count
output.bot = data.bot
if (data.pleroma) {
const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image
output.token = data.pleroma.chat_token
if (relationship) {
- output.follows_you = relationship.followed_by
- output.requested = relationship.requested
- output.following = relationship.following
- output.statusnet_blocking = relationship.blocking
- output.muted = relationship.muting
- output.showing_reblogs = relationship.showing_reblogs
- output.subscribed = relationship.subscribing
+ output.relationship = relationship
}
output.allow_following_move = data.pleroma.allow_following_move
output.hide_follows = data.pleroma.hide_follows
output.hide_followers = data.pleroma.hide_followers
output.hide_follows_count = data.pleroma.hide_follows_count
output.hide_followers_count = data.pleroma.hide_followers_count
output.rights = {
moderator: data.pleroma.is_moderator,
admin: data.pleroma.is_admin
}
// TODO: Clean up in UI? This is duplication from what BE does for qvitterapi
if (output.rights.admin) {
output.role = 'admin'
} else if (output.rights.moderator) {
output.role = 'moderator'
} else {
output.role = 'member'
}
}
if (data.source) {
output.description = data.source.note
output.default_scope = data.source.privacy
output.fields = data.source.fields
if (data.source.pleroma) {
output.no_rich_text = data.source.pleroma.no_rich_text
output.show_role = data.source.pleroma.show_role
output.discoverable = data.source.pleroma.discoverable
}
}
// TODO: handle is_local
output.is_local = !output.screen_name.includes('@')
} else {
output.screen_name = data.screen_name
output.name = data.name
output.name_html = data.name_html
output.description = data.description
output.description_html = data.description_html
output.profile_image_url = data.profile_image_url
output.profile_image_url_original = data.profile_image_url_original
output.cover_photo = data.cover_photo
output.friends_count = data.friends_count
// output.bot = ??? missing
output.statusnet_profile_url = data.statusnet_profile_url
- output.statusnet_blocking = data.statusnet_blocking
-
output.is_local = data.is_local
output.role = data.role
output.show_role = data.show_role
- output.follows_you = data.follows_you
-
- output.muted = data.muted
-
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
output.hide_followers = data.hide_followers
output.hide_follows_count = data.hide_follows_count
output.hide_followers_count = data.hide_followers_count
output.background_image = data.background_image
- // on mastoapi this info is contained in a "relationship"
- output.following = data.following
// Websocket token
output.token = data.token
+
+ // Convert relationsip data to expected format
+ output.relationship = {
+ muting: data.muted,
+ blocking: data.statusnet_blocking,
+ followed_by: data.follows_you,
+ following: data.following
+ }
}
output.created_at = new Date(data.created_at)
output.locked = data.locked
output.followers_count = data.followers_count
output.statuses_count = data.statuses_count
output.friendIds = []
output.followerIds = []
output.pinnedStatusIds = []
if (data.pleroma) {
output.follow_request_count = data.pleroma.follow_request_count
output.tags = data.pleroma.tags
output.deactivated = data.pleroma.deactivated
output.notification_settings = data.pleroma.notification_settings
}
output.tags = output.tags || []
output.rights = output.rights || {}
output.notification_settings = output.notification_settings || {}
return output
}
export const parseAttachment = (data) => {
const output = {}
const masto = !data.hasOwnProperty('oembed')
if (masto) {
// Not exactly same...
output.mimetype = data.pleroma ? data.pleroma.mime_type : data.type
output.meta = data.meta // not present in BE yet
output.id = data.id
} else {
output.mimetype = data.mimetype
// output.meta = ??? missing
}
output.url = data.url
output.description = data.description
return output
}
export const addEmojis = (string, emojis) => {
const matchOperatorsRegex = /[|\\{}()[\]^$+*?.-]/g
return emojis.reduce((acc, emoji) => {
const regexSafeShortCode = emoji.shortcode.replace(matchOperatorsRegex, '\\$&')
return acc.replace(
new RegExp(`:${regexSafeShortCode}:`, 'g'),
`<img src='${emoji.url}' alt='${emoji.shortcode}' title='${emoji.shortcode}' class='emoji' />`
)
}, string)
}
export const parseStatus = (data) => {
const output = {}
const masto = data.hasOwnProperty('account')
if (masto) {
output.favorited = data.favourited
output.fave_num = data.favourites_count
output.repeated = data.reblogged
output.repeat_num = data.reblogs_count
output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive
output.statusnet_html = addEmojis(data.content, data.emojis)
output.tags = data.tags
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
output.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions
} 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
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.reblog)
}
output.summary_html = addEmojis(escape(data.spoiler_text), data.emojis)
output.external_url = data.url
output.poll = data.poll
output.pinned = data.pinned
output.muted = data.muted
} else {
output.favorited = data.favorited
output.fave_num = data.fave_num
output.repeated = data.repeated
output.repeat_num = data.repeat_num
// catchall, temporary
// Object.assign(output, data)
output.type = qvitterStatusType(data)
if (data.nsfw === undefined) {
output.nsfw = isNsfw(data)
if (data.retweeted_status) {
output.nsfw = data.retweeted_status.nsfw
}
} else {
output.nsfw = data.nsfw
}
output.statusnet_html = data.statusnet_html
output.text = data.text
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') {
output.retweeted_status = parseStatus(data.retweeted_status)
}
output.summary = data.summary
output.summary_html = data.summary_html
output.external_url = data.external_url
output.is_local = data.is_local
}
output.id = String(data.id)
output.visibility = data.visibility
output.card = data.card
output.created_at = new Date(data.created_at)
// Converting to string, the right way.
output.in_reply_to_status_id = output.in_reply_to_status_id
? String(output.in_reply_to_status_id)
: null
output.in_reply_to_user_id = output.in_reply_to_user_id
? String(output.in_reply_to_user_id)
: null
output.user = parseUser(masto ? data.account : data.user)
output.attentions = ((masto ? data.mentions : data.attentions) || []).map(parseUser)
output.attachments = ((masto ? data.media_attachments : data.attachments) || [])
.map(parseAttachment)
const retweetedStatus = masto ? data.reblog : data.retweeted_status
if (retweetedStatus) {
output.retweeted_status = parseStatus(retweetedStatus)
}
output.favoritedBy = []
output.rebloggedBy = []
return output
}
export const parseNotification = (data) => {
const mastoDict = {
'favourite': 'like',
'reblog': 'repeat'
}
const masto = !data.hasOwnProperty('ntype')
const output = {}
if (masto) {
output.type = mastoDict[data.type] || data.type
output.seen = data.pleroma.is_seen
output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null
output.action = output.status // TODO: Refactor, this is unneeded
output.target = output.type !== 'move'
? null
: parseUser(data.target)
output.from_profile = parseUser(data.account)
output.emoji = data.emoji
} else {
const parsedNotice = parseStatus(data.notice)
output.type = data.ntype
output.seen = Boolean(data.is_seen)
output.status = output.type === 'like'
? parseStatus(data.notice.favorited_status)
: parsedNotice
output.action = parsedNotice
output.from_profile = parseUser(data.from_profile)
}
output.created_at = new Date(data.created_at)
output.id = parseInt(data.id)
return output
}
const isNsfw = (status) => {
const nsfwRegex = /#nsfw/i
return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex)
}
diff --git a/src/services/follow_manipulate/follow_manipulate.js b/src/services/follow_manipulate/follow_manipulate.js
index 29b38a0f49..08f4c4d631 100644
--- a/src/services/follow_manipulate/follow_manipulate.js
+++ b/src/services/follow_manipulate/follow_manipulate.js
@@ -1,49 +1,52 @@
-const fetchUser = (attempt, user, store) => new Promise((resolve, reject) => {
+const fetchRelationship = (attempt, userId, store) => new Promise((resolve, reject) => {
setTimeout(() => {
- store.state.api.backendInteractor.fetchUser({ id: user.id })
- .then((user) => store.commit('addNewUsers', [user]))
- .then(() => resolve([user.following, user.requested, user.locked, attempt]))
+ store.state.api.backendInteractor.fetchUserRelationship({ id: userId })
+ .then((relationship) => {
+ store.commit('updateUserRelationship', [relationship])
+ return relationship
+ })
+ .then((relationship) => resolve([relationship.following, relationship.requested, relationship.locked, attempt]))
.catch((e) => reject(e))
}, 500)
}).then(([following, sent, locked, attempt]) => {
if (!following && !(locked && sent) && attempt <= 3) {
// If we BE reports that we still not following that user - retry,
// increment attempts by one
- fetchUser(++attempt, user, store)
+ fetchRelationship(++attempt, userId, store)
}
})
-export const requestFollow = (user, store) => new Promise((resolve, reject) => {
- store.state.api.backendInteractor.followUser({ id: user.id })
+export const requestFollow = (userId, store) => new Promise((resolve, reject) => {
+ store.state.api.backendInteractor.followUser({ id: userId })
.then((updated) => {
store.commit('updateUserRelationship', [updated])
- if (updated.following || (user.locked && user.requested)) {
+ if (updated.following || (updated.locked && updated.requested)) {
// If we get result immediately or the account is locked, just stop.
resolve()
return
}
// But usually we don't get result immediately, so we ask server
// for updated user profile to confirm if we are following them
// Sometimes it takes several tries. Sometimes we end up not following
// user anyway, probably because they locked themselves and we
// don't know that yet.
// Recursive Promise, it will call itself up to 3 times.
- return fetchUser(1, user, store)
+ return fetchRelationship(1, updated, store)
.then(() => {
resolve()
})
})
})
-export const requestUnfollow = (user, store) => new Promise((resolve, reject) => {
- store.state.api.backendInteractor.unfollowUser({ id: user.id })
+export const requestUnfollow = (userId, store) => new Promise((resolve, reject) => {
+ store.state.api.backendInteractor.unfollowUser({ id: userId })
.then((updated) => {
store.commit('updateUserRelationship', [updated])
resolve({
updated
})
})
})
diff --git a/test/unit/specs/components/user_profile.spec.js b/test/unit/specs/components/user_profile.spec.js
index 1b6a47d7aa..dcf066f9a3 100644
--- a/test/unit/specs/components/user_profile.spec.js
+++ b/test/unit/specs/components/user_profile.spec.js
@@ -1,204 +1,207 @@
import { mount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import UserProfile from 'src/components/user_profile/user_profile.vue'
import backendInteractorService from 'src/services/backend_interactor_service/backend_interactor_service.js'
import { getters } from 'src/modules/users.js'
const localVue = createLocalVue()
localVue.use(Vuex)
const mutations = {
clearTimeline: () => {},
setError: () => {}
}
const actions = {
fetchUser: () => {},
fetchUserByScreenName: () => {}
}
const testGetters = {
findUser: state => getters.findUser(state.users),
+ relationship: state => getters.relationship(state.users),
mergedConfig: state => ({
colors: '',
highlight: {},
customTheme: {
colors: []
}
})
}
const localUser = {
id: 100,
is_local: true,
screen_name: 'testUser'
}
const extUser = {
id: 100,
is_local: false,
screen_name: 'testUser@test.instance'
}
const externalProfileStore = new Vuex.Store({
mutations,
actions,
getters: testGetters,
state: {
api: {
fetchers: {},
backendInteractor: backendInteractorService('')
},
interface: {
browserSupport: ''
},
instance: {
hideUserStats: true
},
statuses: {
timelines: {
user: {
statuses: [],
statusesObject: {},
faves: [],
visibleStatuses: [],
visibleStatusesObject: {},
newStatusCount: 0,
maxId: 0,
minVisibleId: 0,
loading: false,
followers: [],
friends: [],
viewing: 'statuses',
userId: 100,
flushMarker: 0
},
media: {
statuses: [],
statusesObject: {},
faves: [],
visibleStatuses: [],
visibleStatusesObject: {},
newStatusCount: 0,
maxId: 0,
minVisibleId: 0,
loading: false,
followers: [],
friends: [],
viewing: 'statuses',
userId: 100,
flushMarker: 0
}
}
},
users: {
currentUser: {
credentials: ''
},
usersObject: { 100: extUser },
- users: [extUser]
+ users: [extUser],
+ relationships: {}
}
}
})
const localProfileStore = new Vuex.Store({
mutations,
actions,
getters: testGetters,
state: {
api: {
fetchers: {},
backendInteractor: backendInteractorService('')
},
interface: {
browserSupport: ''
},
config: {
colors: '',
highlight: {},
customTheme: {
colors: []
}
},
instance: {
hideUserStats: true
},
statuses: {
timelines: {
user: {
statuses: [],
statusesObject: {},
faves: [],
visibleStatuses: [],
visibleStatusesObject: {},
newStatusCount: 0,
maxId: 0,
minVisibleId: 0,
loading: false,
followers: [],
friends: [],
viewing: 'statuses',
userId: 100,
flushMarker: 0
},
media: {
statuses: [],
statusesObject: {},
faves: [],
visibleStatuses: [],
visibleStatusesObject: {},
newStatusCount: 0,
maxId: 0,
minVisibleId: 0,
loading: false,
followers: [],
friends: [],
viewing: 'statuses',
userId: 100,
flushMarker: 0
}
}
},
users: {
currentUser: {
credentials: ''
},
usersObject: { 100: localUser, 'testuser': localUser },
- users: [localUser]
+ users: [localUser],
+ relationships: {}
}
}
})
describe('UserProfile', () => {
it('renders external profile', () => {
const wrapper = mount(UserProfile, {
localVue,
store: externalProfileStore,
mocks: {
$route: {
params: { id: 100 },
name: 'external-user-profile'
},
$t: (msg) => msg
}
})
expect(wrapper.find('.user-screen-name').text()).to.eql('@testUser@test.instance')
})
it('renders local profile', () => {
const wrapper = mount(UserProfile, {
localVue,
store: localProfileStore,
mocks: {
$route: {
params: { name: 'testUser' },
name: 'user-profile'
},
$t: (msg) => msg
}
})
expect(wrapper.find('.user-screen-name').text()).to.eql('@testUser')
})
})
diff --git a/test/unit/specs/modules/users.spec.js b/test/unit/specs/modules/users.spec.js
index eeb7afefe0..670acfc822 100644
--- a/test/unit/specs/modules/users.spec.js
+++ b/test/unit/specs/modules/users.spec.js
@@ -1,64 +1,50 @@
import { cloneDeep } from 'lodash'
import { defaultState, mutations, getters } from '../../../../src/modules/users.js'
describe('The users module', () => {
describe('mutations', () => {
it('adds new users to the set, merging in new information for old users', () => {
const state = cloneDeep(defaultState)
const user = { id: '1', name: 'Guy' }
const modUser = { id: '1', name: 'Dude' }
mutations.addNewUsers(state, [user])
expect(state.users).to.have.length(1)
expect(state.users).to.eql([user])
mutations.addNewUsers(state, [modUser])
expect(state.users).to.have.length(1)
expect(state.users).to.eql([user])
expect(state.users[0].name).to.eql('Dude')
})
-
- it('sets a mute bit on users', () => {
- const state = cloneDeep(defaultState)
- const user = { id: '1', name: 'Guy' }
-
- mutations.addNewUsers(state, [user])
- mutations.setMuted(state, { user, muted: true })
-
- expect(user.muted).to.eql(true)
-
- mutations.setMuted(state, { user, muted: false })
-
- expect(user.muted).to.eql(false)
- })
})
describe('findUser', () => {
it('returns user with matching screen_name', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
usersObject: {
1: user,
guy: user
}
}
const name = 'Guy'
const expected = { screen_name: 'Guy', id: '1' }
expect(getters.findUser(state)(name)).to.eql(expected)
})
it('returns user with matching id', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
usersObject: {
1: user,
guy: user
}
}
const id = '1'
const expected = { screen_name: 'Guy', id: '1' }
expect(getters.findUser(state)(id)).to.eql(expected)
})
})
})

File Metadata

Mime Type
text/x-diff
Expires
Sat, Aug 29, 3:18 AM (1 d, 5 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1736773
Default Alt Text
(152 KB)

Event Timeline