Merge remote-tracking branch 'upstream/develop' into minimal-scopes-mode

* upstream/develop: (173 commits)
  Fix: Change condition
  fix typo
  update store according to retweeted status
  #433 - update sort by for conversation
  display replies_count right after reply icon
  expose replies_count from mastodon api
  Apparently, MastoAPI gives status in ancestors if you try opening a repeat...
  make side drawer use gesture service and fix its animations
  review/remove error hiding
  errata
  review
  #433 - sort conversation for retweets and clean up
  Revert "Merge branch 'revert-987b5162' into 'develop'"
  Revert "Merge branch 'mastoapi/friends-tl' into 'develop'"
  Add await to login action'
  Remove console log
  Fix warnings in user profile routing
  Add tests for gesture service, fix bug with perpendicular directions
  #255 - clean up autocomplete form
  #255 - clean up user settings page with self-closing html tags
  ...
This commit is contained in:
Henry Jameson 2019-03-30 12:31:50 +02:00
commit 9f4a9bff46
115 changed files with 3612 additions and 1222 deletions

View file

@ -160,6 +160,7 @@
.hider {
position: absolute;
right: 0;
white-space: nowrap;
margin: 10px;
padding: 5px;

View file

@ -1,4 +1,4 @@
import UserCardContent from '../user_card_content/user_card_content.vue'
import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -12,7 +12,7 @@ const BasicUserCard = {
}
},
components: {
UserCardContent,
UserCard,
UserAvatar
},
methods: {

View file

@ -1,18 +1,18 @@
<template>
<div class="user-card">
<div class="basic-user-card">
<router-link :to="userProfileLink(user)">
<UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/>
</router-link>
<div class="user-card-expanded-content" v-if="userExpanded">
<user-card-content :user="user" :switcher="false"></user-card-content>
<div class="basic-user-card-expanded-content" v-if="userExpanded">
<UserCard :user="user" :rounded="true" :bordered="true"/>
</div>
<div class="user-card-collapsed-content" v-else>
<div :title="user.name" class="user-card-user-name">
<span v-if="user.name_html" v-html="user.name_html"></span>
<span v-else>{{ user.name }}</span>
<div class="basic-user-card-collapsed-content" v-else>
<div :title="user.name" class="basic-user-card-user-name">
<span v-if="user.name_html" class="basic-user-card-user-name-value" v-html="user.name_html"></span>
<span v-else class="basic-user-card-user-name-value">{{ user.name }}</span>
</div>
<div>
<router-link class="user-card-screen-name" :to="userProfileLink(user)">
<router-link class="basic-user-card-screen-name" :to="userProfileLink(user)">
@{{user.screen_name}}
</router-link>
</div>
@ -26,15 +26,15 @@
<style lang="scss">
@import '../../_variables.scss';
.user-card {
.basic-user-card {
display: flex;
flex: 1 0;
margin: 0;
padding-top: 0.6em;
padding-right: 1em;
padding-bottom: 0.6em;
padding-left: 1em;
border-bottom: 1px solid;
margin: 0;
border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border);
@ -52,28 +52,19 @@
width: 16px;
vertical-align: middle;
}
&-value {
display: inline-block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
&-expanded-content {
flex: 1;
margin-left: 0.7em;
border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius);
border-style: solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
border-width: 1px;
overflow: hidden;
.panel-heading {
background: transparent;
flex-direction: column;
align-items: stretch;
}
p {
margin-bottom: 0;
}
}
}
</style>

View file

@ -9,7 +9,7 @@ const BlockCard = {
},
computed: {
user () {
return this.$store.getters.userById(this.userId)
return this.$store.getters.findUser(this.userId)
},
blocked () {
return this.user.statusnet_blocking

View file

@ -1,5 +1,4 @@
import Conversation from '../conversation/conversation.vue'
import { find } from 'lodash'
const conversationPage = {
components: {
@ -8,8 +7,8 @@ const conversationPage = {
computed: {
statusoid () {
const id = this.$route.params.id
const statuses = this.$store.state.statuses.allStatuses
const status = find(statuses, {id})
const statuses = this.$store.state.statuses.allStatusesObject
const status = statuses[id]
return status
}

View file

@ -1,5 +1,9 @@
<template>
<conversation :collapsable="false" :statusoid="statusoid"></conversation>
<conversation
:collapsable="false"
isPage="true"
:statusoid="statusoid"
></conversation>
</template>
<script src="./conversation-page.js"></script>

View file

@ -1,9 +1,12 @@
import { reduce, filter } from 'lodash'
import { reduce, filter, findIndex } from 'lodash'
import { set } from 'vue'
import Status from '../status/status.vue'
const sortById = (a, b) => {
const seqA = Number(a.id)
const seqB = Number(b.id)
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
const seqA = Number(idA)
const seqB = Number(idB)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
@ -13,29 +16,53 @@ const sortById = (a, b) => {
} else if (!isSeqA && isSeqB) {
return 1
} else {
return a.id < b.id ? -1 : 1
return idA < idB ? -1 : 1
}
}
const sortAndFilterConversation = (conversation) => {
conversation = filter(conversation, (status) => status.type !== 'retweet')
const sortAndFilterConversation = (conversation, statusoid) => {
if (statusoid.type === 'retweet') {
conversation = filter(
conversation,
(status) => (status.type === 'retweet' || status.id !== statusoid.retweeted_status.id)
)
} else {
conversation = filter(conversation, (status) => status.type !== 'retweet')
}
return conversation.filter(_ => _).sort(sortById)
}
const conversation = {
data () {
return {
highlight: null
highlight: null,
expanded: false,
converationStatusIds: []
}
},
props: [
'statusoid',
'collapsable'
'collapsable',
'isPage'
],
created () {
if (this.isPage) {
this.fetchConversation()
}
},
computed: {
status () {
return this.statusoid
},
idsToShow () {
if (this.converationStatusIds.length > 0) {
return this.converationStatusIds
} else if (this.statusId) {
return [this.statusId]
} else {
return []
}
},
statusId () {
if (this.statusoid.retweeted_status) {
return this.statusoid.retweeted_status.id
@ -48,10 +75,22 @@ const conversation = {
return []
}
const conversationId = this.status.statusnet_conversation_id
const statuses = this.$store.state.statuses.allStatuses
const conversation = filter(statuses, { statusnet_conversation_id: conversationId })
return sortAndFilterConversation(conversation)
if (!this.isExpanded) {
return [this.status]
}
const statusesObject = this.$store.state.statuses.allStatusesObject
const conversation = this.idsToShow.reduce((acc, id) => {
acc.push(statusesObject[id])
return acc
}, [])
const statusIndex = findIndex(conversation, { id: this.statusId })
if (statusIndex !== -1) {
conversation[statusIndex] = this.status
}
return sortAndFilterConversation(conversation, this.status)
},
replies () {
let i = 1
@ -69,23 +108,34 @@ const conversation = {
i++
return result
}, {})
},
isExpanded () {
return this.expanded || this.isPage
}
},
components: {
Status
},
created () {
this.fetchConversation()
},
watch: {
'$route': 'fetchConversation'
'$route': 'fetchConversation',
expanded (value) {
if (value) {
this.fetchConversation()
}
}
},
methods: {
fetchConversation () {
if (this.status) {
const conversationId = this.status.statusnet_conversation_id
this.$store.state.api.backendInteractor.fetchConversation({id: conversationId})
.then((statuses) => this.$store.dispatch('addNewStatuses', { statuses }))
this.$store.state.api.backendInteractor.fetchConversation({id: this.status.id})
.then(({ancestors, descendants}) => {
this.$store.dispatch('addNewStatuses', { statuses: ancestors })
this.$store.dispatch('addNewStatuses', { statuses: descendants })
set(this, 'converationStatusIds', [].concat(
ancestors.map(_ => _.id).filter(_ => _ !== this.statusId),
this.statusId,
descendants.map(_ => _.id).filter(_ => _ !== this.statusId)))
})
.then(() => this.setHighlight(this.statusId))
} else {
const id = this.$route.params.id
@ -98,10 +148,19 @@ const conversation = {
return this.replies[id] || []
},
focused (id) {
return id === this.statusId
return (this.isExpanded) && id === this.status.id
},
setHighlight (id) {
this.highlight = id
},
getHighlight () {
return this.isExpanded ? this.highlight : null
},
toggleExpanded () {
this.expanded = !this.expanded
if (!this.expanded) {
this.setHighlight(null)
}
}
}
}

View file

@ -1,26 +1,42 @@
<template>
<div class="timeline panel panel-default">
<div class="panel-heading conversation-heading">
<div class="timeline panel-default" :class="[isExpanded ? 'panel' : 'panel-disabled']">
<div v-if="isExpanded" class="panel-heading conversation-heading">
<span class="title"> {{ $t('timeline.conversation') }} </span>
<span v-if="collapsable">
<a href="#" @click.prevent="$emit('toggleExpanded')">{{ $t('timeline.collapse') }}</a>
<a href="#" @click.prevent="toggleExpanded">{{ $t('timeline.collapse') }}</a>
</span>
</div>
<div class="panel-body">
<div class="timeline">
<status
v-for="status in conversation"
@goto="setHighlight" :key="status.id"
:inlineExpanded="collapsable" :statusoid="status"
:expandable='false' :focused="focused(status.id)"
:inConversation='true'
:highlight="highlight"
:replies="getReplies(status.id)"
class="status-fadein">
</status>
</div>
</div>
<status
v-for="status in conversation"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
:key="status.id"
:inlineExpanded="collapsable"
:statusoid="status"
:expandable='!expanded'
:focused="focused(status.id)"
:inConversation="isExpanded"
:highlight="getHighlight()"
:replies="getReplies(status.id)"
class="status-fadein panel-body"
/>
</div>
</template>
<script src="./conversation.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.timeline {
.panel-disabled {
.status-el {
border-left: none;
border-bottom-width: 1px;
border-bottom-style: solid;
border-color: var(--border, $fallback--border);
border-radius: 0;
}
}
}
</style>

View file

@ -0,0 +1,107 @@
import Completion from '../../services/completion/completion.js'
import { take, filter, map } from 'lodash'
const EmojiInput = {
props: [
'value',
'placeholder',
'type',
'classname'
],
data () {
return {
highlighted: 0,
caret: 0
}
},
computed: {
suggestions () {
const firstchar = this.textAtCaret.charAt(0)
if (firstchar === ':') {
if (this.textAtCaret === ':') { return }
const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1)))
if (matchedEmoji.length <= 0) {
return false
}
return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({
shortcode: `:${shortcode}:`,
utf: utf || '',
// eslint-disable-next-line camelcase
img: utf ? '' : this.$store.state.instance.server + image_url,
highlighted: index === this.highlighted
}))
} else {
return false
}
},
textAtCaret () {
return (this.wordAtCaret || {}).word || ''
},
wordAtCaret () {
const word = Completion.wordAtPosition(this.value, this.caret - 1) || {}
return word
},
emoji () {
return this.$store.state.instance.emoji || []
},
customEmoji () {
return this.$store.state.instance.customEmoji || []
}
},
methods: {
replace (replacement) {
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.caret = 0
},
replaceEmoji (e) {
const len = this.suggestions.length || 0
if (this.textAtCaret === ':' || e.ctrlKey) { return }
if (len > 0) {
e.preventDefault()
const emoji = this.suggestions[this.highlighted]
const replacement = emoji.utf || (emoji.shortcode + ' ')
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.caret = 0
this.highlighted = 0
}
},
cycleBackward (e) {
const len = this.suggestions.length || 0
if (len > 0) {
e.preventDefault()
this.highlighted -= 1
if (this.highlighted < 0) {
this.highlighted = this.suggestions.length - 1
}
} else {
this.highlighted = 0
}
},
cycleForward (e) {
const len = this.suggestions.length || 0
if (len > 0) {
if (e.shiftKey) { return }
e.preventDefault()
this.highlighted += 1
if (this.highlighted >= len) {
this.highlighted = 0
}
} else {
this.highlighted = 0
}
},
onKeydown (e) {
e.stopPropagation()
},
onInput (e) {
this.$emit('input', e.target.value)
},
setCaret ({target: {selectionStart}}) {
this.caret = selectionStart
}
}
}
export default EmojiInput

View file

@ -0,0 +1,64 @@
<template>
<div class="emoji-input">
<input
v-if="type !== 'textarea'"
:class="classname"
:type="type"
:value="value"
:placeholder="placeholder"
@input="onInput"
@click="setCaret"
@keyup="setCaret"
@keydown="onKeydown"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceEmoji"
/>
<textarea
v-else
:class="classname"
:value="value"
:placeholder="placeholder"
@input="onInput"
@click="setCaret"
@keyup="setCaret"
@keydown="onKeydown"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceEmoji"
></textarea>
<div class="autocomplete-panel" v-if="suggestions">
<div class="autocomplete-panel-body">
<div
v-for="(emoji, index) in suggestions"
:key="index"
@click="replace(emoji.utf || (emoji.shortcode + ' '))"
class="autocomplete-item"
:class="{ highlighted: emoji.highlighted }"
>
<span v-if="emoji.img">
<img :src="emoji.img" />
</span>
<span v-else>{{emoji.utf}}</span>
<span>{{emoji.shortcode}}</span>
</div>
</div>
</div>
</div>
</template>
<script src="./emoji-input.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.emoji-input {
.form-control {
width: 100%;
}
}
</style>

View file

@ -1,4 +1,5 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
const FollowCard = {
@ -14,13 +15,17 @@ const FollowCard = {
}
},
components: {
BasicUserCard
BasicUserCard,
RemoteFollow
},
computed: {
isMe () { return this.$store.state.users.currentUser.id === this.user.id },
following () { return this.updated ? this.updated.following : this.user.following },
showFollow () {
return !this.following || this.updated && !this.updated.following
},
loggedIn () {
return this.$store.state.users.currentUser
}
},
methods: {

View file

@ -4,9 +4,12 @@
<span class="faint" v-if="!noFollowsYou && user.follows_you">
{{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span>
<div class="follow-card-follow-button" v-if="showFollow && !loggedIn">
<RemoteFollow :user="user" />
</div>
<button
v-if="showFollow"
class="btn btn-default"
v-if="showFollow && loggedIn"
class="btn btn-default follow-card-follow-button"
@click="followUser"
:disabled="inProgress"
:title="requestSent ? $t('user_card.follow_again') : ''"
@ -21,7 +24,7 @@
{{ $t('user_card.follow') }}
</template>
</button>
<button v-if="following" class="btn btn-default pressed" @click="unfollowUser" :disabled="inProgress">
<button v-if="following" class="btn btn-default follow-card-follow-button pressed" @click="unfollowUser" :disabled="inProgress">
<template v-if="inProgress">
{{ $t('user_card.follow_progress') }}
</template>
@ -36,15 +39,17 @@
<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-card {
&-content-container {
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
flex-wrap: wrap;
line-height: 1.5em;
}
.btn {
&-follow-button {
margin-top: 0.5em;
margin-left: auto;
width: 10em;

View file

@ -27,7 +27,6 @@
align-content: stretch;
flex-grow: 1;
margin-top: 0.5em;
margin-bottom: 0.25em;
.attachments, .attachment {
margin: 0 0.5em 0 0;

View file

@ -31,6 +31,9 @@ const ImageCropper = {
saveButtonLabel: {
type: String
},
saveWithoutCroppingButtonlabel: {
type: String
},
cancelButtonLabel: {
type: String
}
@ -48,6 +51,9 @@ const ImageCropper = {
saveText () {
return this.saveButtonLabel || this.$t('image_cropper.save')
},
saveWithoutCroppingText () {
return this.saveWithoutCroppingButtonlabel || this.$t('image_cropper.save_without_cropping')
},
cancelText () {
return this.cancelButtonLabel || this.$t('image_cropper.cancel')
},
@ -67,7 +73,19 @@ const ImageCropper = {
submit () {
this.submitting = true
this.avatarUploadError = null
this.submitHandler(this.cropper, this.filename)
this.submitHandler(this.cropper, this.file)
.then(() => this.destroy())
.catch((err) => {
this.submitError = err
})
.finally(() => {
this.submitting = false
})
},
submitWithoutCropping () {
this.submitting = true
this.avatarUploadError = null
this.submitHandler(false, this.dataUrl)
.then(() => this.destroy())
.catch((err) => {
this.submitError = err
@ -88,14 +106,14 @@ const ImageCropper = {
readFile () {
const fileInput = this.$refs.input
if (fileInput.files != null && fileInput.files[0] != null) {
this.file = fileInput.files[0]
let reader = new window.FileReader()
reader.onload = (e) => {
this.dataUrl = e.target.result
this.$emit('open')
}
reader.readAsDataURL(fileInput.files[0])
this.filename = fileInput.files[0].name || 'unknown'
this.$emit('changed', fileInput.files[0], reader)
reader.readAsDataURL(this.file)
this.$emit('changed', this.file, reader)
}
},
clearError () {

View file

@ -7,6 +7,7 @@
<div class="image-cropper-buttons-wrapper">
<button class="btn" type="button" :disabled="submitting" @click="submit" v-text="saveText"></button>
<button class="btn" type="button" :disabled="submitting" @click="destroy" v-text="cancelText"></button>
<button class="btn" type="button" :disabled="submitting" @click="submitWithoutCropping" v-text="saveWithoutCroppingText"></button>
<i class="icon-spin4 animate-spin" v-if="submitting"></i>
</div>
<div class="alert error" v-if="submitError">
@ -36,7 +37,11 @@
}
&-buttons-wrapper {
margin-top: 15px;
margin-top: 10px;
button {
margin-top: 5px;
}
}
}
</style>

View file

@ -23,6 +23,7 @@
flex-direction: row;
cursor: pointer;
overflow: hidden;
margin-top: 0.5em;
.card-image {
flex-shrink: 0;

View file

@ -1,5 +1,5 @@
<template>
<div class="modal-view" v-if="showing" @click.prevent="hide">
<div class="modal-view media-modal-view" v-if="showing" @click.prevent="hide">
<img class="modal-image" v-if="type === 'image'" :src="currentMedia.url"></img>
<VideoAttachment
class="modal-image"
@ -32,18 +32,7 @@
<style lang="scss">
@import '../../_variables.scss';
.modal-view {
z-index: 1000;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
.media-modal-view {
&:hover {
.modal-view-button-arrow {
opacity: 0.75;

View file

@ -20,7 +20,7 @@ const mediaUpload = {
return
}
const formData = new FormData()
formData.append('media', file)
formData.append('file', file)
self.$emit('uploading')
self.uploading = true

View file

@ -0,0 +1,91 @@
import PostStatusForm from '../post_status_form/post_status_form.vue'
import { throttle } from 'lodash'
const MobilePostStatusModal = {
components: {
PostStatusForm
},
data () {
return {
hidden: false,
postFormOpen: false,
scrollingDown: false,
inputActive: false,
oldScrollPos: 0,
amountScrolled: 0
}
},
created () {
window.addEventListener('scroll', this.handleScroll)
window.addEventListener('resize', this.handleOSK)
},
destroyed () {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleOSK)
},
computed: {
currentUser () {
return this.$store.state.users.currentUser
},
isHidden () {
return this.hidden || this.inputActive
}
},
methods: {
openPostForm () {
this.postFormOpen = true
this.hidden = true
const el = this.$el.querySelector('textarea')
this.$nextTick(function () {
el.focus()
})
},
closePostForm () {
this.postFormOpen = false
this.hidden = false
},
handleOSK () {
// This is a big hack: we're guessing from changed window sizes if the
// on-screen keyboard is active or not. This is only really important
// for phones in portrait mode and it's more important to show the button
// in normal scenarios on all phones, than it is to hide it when the
// keyboard is active.
// Guesswork based on https://www.mydevice.io/#compare-devices
// for example, iphone 4 and android phones from the same time period
const smallPhone = window.innerWidth < 350
const smallPhoneKbOpen = smallPhone && window.innerHeight < 345
const biggerPhone = !smallPhone && window.innerWidth < 450
const biggerPhoneKbOpen = biggerPhone && window.innerHeight < 560
if (smallPhoneKbOpen || biggerPhoneKbOpen) {
this.inputActive = true
} else {
this.inputActive = false
}
},
handleScroll: throttle(function () {
const scrollAmount = window.scrollY - this.oldScrollPos
const scrollingDown = scrollAmount > 0
if (scrollingDown !== this.scrollingDown) {
this.amountScrolled = 0
this.scrollingDown = scrollingDown
if (!scrollingDown) {
this.hidden = false
}
} else if (scrollingDown) {
this.amountScrolled += scrollAmount
if (this.amountScrolled > 100 && !this.hidden) {
this.hidden = true
}
}
this.oldScrollPos = window.scrollY
this.scrollingDown = scrollingDown
}, 100)
}
}
export default MobilePostStatusModal

View file

@ -0,0 +1,76 @@
<template>
<div v-if="currentUser">
<div
class="post-form-modal-view modal-view"
v-show="postFormOpen"
@click="closePostForm"
>
<div class="post-form-modal-panel panel" @click.stop="">
<div class="panel-heading">{{$t('post_status.new_status')}}</div>
<PostStatusForm class="panel-body" @posted="closePostForm"/>
</div>
</div>
<button
class="new-status-button"
:class="{ 'hidden': isHidden }"
@click="openPostForm"
>
<i class="icon-edit" />
</button>
</div>
</template>
<script src="./mobile_post_status_modal.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.post-form-modal-view {
max-height: 100%;
display: block;
}
.post-form-modal-panel {
flex-shrink: 0;
margin: 25% 0 4em 0;
width: 100%;
}
.new-status-button {
width: 5em;
height: 5em;
border-radius: 100%;
position: fixed;
bottom: 1.5em;
right: 1.5em;
// TODO: this needs its own color, it has to stand out enough and link color
// is not very optimal for this particular use.
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3), 0px 4px 6px rgba(0, 0, 0, 0.3);
z-index: 10;
transition: 0.35s transform;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
&.hidden {
transform: translateY(150%);
}
i {
font-size: 1.5em;
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
@media all and (min-width: 801px) {
.new-status-button {
display: none;
}
}
</style>

View file

@ -9,7 +9,7 @@ const MuteCard = {
},
computed: {
user () {
return this.$store.getters.userById(this.userId)
return this.$store.getters.findUser(this.userId)
},
muted () {
return this.user.muted

View file

@ -1,6 +1,6 @@
<template>
<basic-user-card :user="user">
<template slot="secondary-area">
<div class="mute-card-content-container">
<button class="btn btn-default" @click="unmuteUser" :disabled="progress" v-if="muted">
<template v-if="progress">
{{ $t('user_card.unmute_progress') }}
@ -17,8 +17,18 @@
{{ $t('user_card.mute') }}
</template>
</button>
</template>
</div>
</basic-user-card>
</template>
<script src="./mute_card.js"></script>
<script src="./mute_card.js"></script>
<style lang="scss">
.mute-card-content-container {
margin-top: 0.5em;
text-align: right;
button {
width: 10em;
}
}
</style>

View file

@ -1,6 +1,6 @@
import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCardContent from '../user_card_content/user_card_content.vue'
import UserCard from '../user_card/user_card.vue'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -13,7 +13,7 @@ const Notification = {
},
props: [ 'notification' ],
components: {
Status, UserAvatar, UserCardContent
Status, UserAvatar, UserCard
},
methods: {
toggleUserExpanded () {

View file

@ -5,9 +5,7 @@
<UserAvatar :compact="true" :betterShadow="betterShadow" :src="notification.action.user.profile_image_url_original"/>
</a>
<div class='notification-right'>
<div class="usercard notification-usercard" v-if="userExpanded">
<user-card-content :user="notification.action.user" :switcher="false"></user-card-content>
</div>
<UserCard :user="notification.action.user" :rounded="true" :bordered="true" v-if="userExpanded"/>
<span class="notification-details">
<div class="name-and-action">
<span class="username" v-if="!!notification.action.user.name_html" :title="'@'+notification.action.user.screen_name" v-html="notification.action.user.name_html"></span>
@ -25,7 +23,11 @@
<small>{{$t('notifications.followed_you')}}</small>
</span>
</div>
<small class="timeago"><router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small>
<div class="timeago">
<router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" class="faint-link">
<timeago :since="notification.action.created_at" :auto-update="240"></timeago>
</router-link>
</div>
</span>
<div class="follow-text" v-if="notification.type === 'follow'">
<router-link :to="userProfileLink(notification.action.user)">

View file

@ -11,7 +11,8 @@ const Notifications = {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
notificationsFetcher.startFetching({ store, credentials })
const fetcherId = notificationsFetcher.startFetching({ store, credentials })
this.$store.commit('setNotificationFetcher', { fetcherId })
},
data () {
return {

View file

@ -45,10 +45,6 @@
}
}
.notification-usercard {
margin: 0;
}
.non-mention {
display: flex;
flex: 1;
@ -126,7 +122,7 @@
}
.timeago {
font-size: 12px;
margin-right: .2em;
}
.icon-retweet.lit {

View file

@ -1,6 +1,7 @@
import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji-input/emoji-input.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import Completion from '../../services/completion/completion.js'
import { take, filter, reject, map, uniqBy } from 'lodash'
@ -30,7 +31,8 @@ const PostStatusForm = {
],
components: {
MediaUpload,
ScopeSelector
ScopeSelector,
EmojiInput
},
mounted () {
this.resize(this.$refs.textarea)
@ -174,6 +176,9 @@ const PostStatusForm = {
},
formattingOptionsEnabled () {
return this.$store.state.instance.formattingOptionsEnabled
},
postFormats () {
return this.$store.state.instance.postFormats || []
}
},
methods: {
@ -222,6 +227,9 @@ const PostStatusForm = {
this.highlighted = 0
}
},
onKeydown (e) {
e.stopPropagation()
},
setCaret ({target: {selectionStart}}) {
this.caret = selectionStart
},
@ -293,6 +301,8 @@ const PostStatusForm = {
},
paste (e) {
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
// Strangely, files property gets emptied after event propagation
// Trying to wrap it in array doesn't work. Plus I doubt it's possible
// to hold more than one file in clipboard.

View file

@ -10,16 +10,18 @@
<router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link>
</i18n>
<p v-if="this.newStatus.visibility == 'direct'" class="visibility-notice">{{ $t('post_status.direct_warning') }}</p>
<input
<EmojiInput
v-if="newStatus.spoilerText || alwaysShowSubject"
type="text"
:placeholder="$t('post_status.content_warning')"
v-model="newStatus.spoilerText"
class="form-cw">
classname="form-control"
/>
<textarea
ref="textarea"
@click="setCaret"
@keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control"
@keydown="onKeydown"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@ -30,15 +32,17 @@
@drop="fileDrop"
@dragover.prevent="fileDrag"
@input="resize"
@paste="paste">
@paste="paste"
:disabled="posting"
>
</textarea>
<div class="visibility-tray">
<span class="text-format" v-if="formattingOptionsEnabled">
<label for="post-content-type" class="select">
<select id="post-content-type" v-model="newStatus.contentType" class="form-control">
<option value="text/plain">{{$t('post_status.content_type.plain_text')}}</option>
<option value="text/html">HTML</option>
<option value="text/markdown">Markdown</option>
<option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat">
{{$t(`post_status.content_type["${postFormat}"]`)}}
</option>
</select>
<i class="icon-down-open"></i>
</label>
@ -52,14 +56,18 @@
:onScopeChange="changeVis"/>
</div>
</div>
<div style="position:relative;" v-if="candidates">
<div class="autocomplete-panel">
<div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))">
<div class="autocomplete" :class="{ highlighted: candidate.highlighted }">
<span v-if="candidate.img"><img :src="candidate.img"></img></span>
<span v-else>{{candidate.utf}}</span>
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
</div>
<div class="autocomplete-panel" v-if="candidates">
<div class="autocomplete-panel-body">
<div
v-for="(candidate, index) in candidates"
:key="index"
@click="replace(candidate.utf || (candidate.screen_name + ' '))"
class="autocomplete-item"
:class="{ highlighted: candidate.highlighted }"
>
<span v-if="candidate.img"><img :src="candidate.img" /></span>
<span v-else>{{candidate.utf}}</span>
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
</div>
</div>
</div>
@ -81,10 +89,10 @@
<div class="media-upload-wrapper" v-for="file in newStatus.files">
<i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i>
<div class="media-upload-container attachment">
<img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img>
<video v-if="type(file) === 'video'" :src="file.image" controls></video>
<audio v-if="type(file) === 'audio'" :src="file.image" controls></audio>
<a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a>
<img class="thumbnail media-upload" :src="file.url" v-if="type(file) === 'image'"></img>
<video v-if="type(file) === 'video'" :src="file.url" controls></video>
<audio v-if="type(file) === 'audio'" :src="file.url" controls></audio>
<a v-if="type(file) === 'unknown'" :href="file.url">{{file.url}}</a>
</div>
</div>
</div>
@ -258,52 +266,5 @@
cursor: pointer;
z-index: 4;
}
.autocomplete-panel {
margin: 0 0.5em 0 0.5em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
position: absolute;
z-index: 1;
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
// this doesn't match original but i don't care, making it uniform.
box-shadow: var(--popupShadow);
min-width: 75%;
background: $fallback--bg;
background: var(--bg, $fallback--bg);
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
.autocomplete {
cursor: pointer;
padding: 0.2em 0.4em 0.2em 0.4em;
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
display: flex;
img {
width: 24px;
height: 24px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
object-fit: contain;
}
span {
line-height: 24px;
margin: 0 0.1em 0 0.2em;
}
small {
margin-left: .5em;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
&.highlighted {
background-color: $fallback--fg;
background-color: var(--lightBg, $fallback--fg);
}
}
}
</style>

View file

@ -35,6 +35,9 @@ const registration = {
},
computed: {
token () { return this.$route.params.token },
bioPlaceholder () {
return this.$t('registration.bio_placeholder').replace(/\s*\n\s*/g, ' \n')
},
...mapState({
registrationOpen: (state) => state.instance.registrationOpen,
signedIn: (state) => !!state.users.currentUser,

View file

@ -45,7 +45,7 @@
<div class='form-group'>
<label class='form--label' for='bio'>{{$t('registration.bio')}} ({{$t('general.optional')}})</label>
<textarea :disabled="isPending" v-model='user.bio' class='form-control' id='bio' :placeholder="$t('registration.bio_placeholder')"></textarea>
<textarea :disabled="isPending" v-model='user.bio' class='form-control' id='bio' :placeholder="bioPlaceholder"></textarea>
</div>
<div class='form-group' :class="{ 'form-group--error': $v.user.password.$error }">

View file

@ -0,0 +1,10 @@
export default {
props: [ 'user' ],
computed: {
subscribeUrl () {
// eslint-disable-next-line no-undef
const serverUrl = new URL(this.user.statusnet_profile_url)
return `${serverUrl.protocol}//${serverUrl.host}/main/ostatus`
}
}
}

View file

@ -0,0 +1,24 @@
<template>
<div class="remote-follow">
<form method="POST" :action='subscribeUrl'>
<input type="hidden" name="nickname" :value="user.screen_name">
<input type="hidden" name="profile" value="">
<button click="submit" class="remote-button">
{{ $t('user_card.remote_follow') }}
</button>
</form>
</div>
</template>
<script src="./remote_follow.js"></script>
<style lang="scss">
.remote-follow {
max-width: 220px;
.remote-button {
width: 100%;
min-height: 28px;
}
}
</style>

View file

@ -1,8 +1,13 @@
/* eslint-env browser */
import { filter, trim } from 'lodash'
import TabSwitcher from '../tab_switcher/tab_switcher.js'
import StyleSwitcher from '../style_switcher/style_switcher.vue'
import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue'
import { filter, trim } from 'lodash'
import { extractCommit } from '../../services/version/version.service'
const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
const pleromaBeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma/commit/'
const settings = {
data () {
@ -42,6 +47,11 @@ const settings = {
pauseOnUnfocusedLocal: user.pauseOnUnfocused,
hoverPreviewLocal: user.hoverPreview,
hideMutedPostsLocal: typeof user.hideMutedPosts === 'undefined'
? instance.hideMutedPosts
: user.hideMutedPosts,
hideMutedPostsDefault: this.$t('settings.values.' + instance.hideMutedPosts),
collapseMessageWithSubjectLocal: typeof user.collapseMessageWithSubject === 'undefined'
? instance.collapseMessageWithSubject
: user.collapseMessageWithSubject,
@ -83,7 +93,10 @@ const settings = {
// Future spec, still not supported in Nightly 63 as of 08/2018
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks'),
playVideosInModal: user.playVideosInModal,
useContainFit: user.useContainFit
useContainFit: user.useContainFit,
backendVersion: instance.backendVersion,
frontendVersion: instance.frontendVersion
}
},
components: {
@ -98,7 +111,16 @@ const settings = {
currentSaveStateNotice () {
return this.$store.state.interface.settings.currentSaveStateNotice
},
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel }
postFormats () {
return this.$store.state.instance.postFormats || []
},
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
frontendVersionLink () {
return pleromaFeCommitUrl + this.frontendVersion
},
backendVersionLink () {
return pleromaBeCommitUrl + extractCommit(this.backendVersion)
}
},
watch: {
hideAttachmentsLocal (value) {
@ -165,6 +187,9 @@ const settings = {
value = filter(value.split('\n'), (word) => trim(word).length > 0)
this.$store.dispatch('setOption', { name: 'muteWords', value })
},
hideMutedPostsLocal (value) {
this.$store.dispatch('setOption', { name: 'hideMutedPosts', value })
},
collapseMessageWithSubjectLocal (value) {
this.$store.dispatch('setOption', { name: 'collapseMessageWithSubject', value })
},

View file

@ -36,6 +36,10 @@
<div class="setting-item">
<h2>{{$t('nav.timeline')}}</h2>
<ul class="setting-list">
<li>
<input type="checkbox" id="hideMutedPosts" v-model="hideMutedPostsLocal">
<label for="hideMutedPosts">{{$t('settings.hide_muted_posts')}} {{$t('settings.instance_default', { value: hideMutedPostsDefault })}}</label>
</li>
<li>
<input type="checkbox" id="collapseMessageWithSubject" v-model="collapseMessageWithSubjectLocal">
<label for="collapseMessageWithSubject">
@ -105,17 +109,9 @@
{{$t('settings.post_status_content_type')}}
<label for="postContentType" class="select">
<select id="postContentType" v-model="postContentTypeLocal">
<option value="text/plain">
{{$t('settings.status_content_type_plain')}}
{{postContentTypeDefault == 'text/plain' ? $t('settings.instance_default_simple') : ''}}
</option>
<option value="text/html">
HTML
{{postContentTypeDefault == 'text/html' ? $t('settings.instance_default_simple') : ''}}
</option>
<option value="text/markdown">
Markdown
{{postContentTypeDefault == 'text/markdown' ? $t('settings.instance_default_simple') : ''}}
<option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat">
{{$t(`post_status.content_type["${postFormat}"]`)}}
{{postContentTypeDefault === postFormat ? $t('settings.instance_default_simple') : ''}}
</option>
</select>
<i class="icon-down-open"/>
@ -275,6 +271,28 @@
</div>
</div>
</div>
<div :label="$t('settings.version.title')" >
<div class="setting-item">
<ul class="setting-list">
<li>
<p>{{$t('settings.version.backend_version')}}</p>
<ul class="option-list">
<li>
<a :href="backendVersionLink" target="_blank">{{backendVersion}}</a>
</li>
</ul>
</li>
<li>
<p>{{$t('settings.version.frontend_version')}}</p>
<ul class="option-list">
<li>
<a :href="frontendVersionLink" target="_blank">{{frontendVersion}}</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</tab-switcher>
</keep-alive>
</div>

View file

@ -1,18 +1,17 @@
import UserCardContent from '../user_card_content/user_card_content.vue'
import UserCard from '../user_card/user_card.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
// TODO: separate touch gesture stuff into their own utils if more components want them
const deltaCoord = (oldCoord, newCoord) => [newCoord[0] - oldCoord[0], newCoord[1] - oldCoord[1]]
const touchEventCoord = e => ([e.touches[0].screenX, e.touches[0].screenY])
import GestureService from '../../services/gesture_service/gesture_service'
const SideDrawer = {
props: [ 'logout' ],
data: () => ({
closed: true,
touchCoord: [0, 0]
closeGesture: undefined
}),
components: { UserCardContent },
created () {
this.closeGesture = GestureService.swipeGesture(GestureService.DIRECTION_LEFT, this.toggleDrawer)
},
components: { UserCard },
computed: {
currentUser () {
return this.$store.state.users.currentUser
@ -46,13 +45,10 @@ const SideDrawer = {
this.toggleDrawer()
},
touchStart (e) {
this.touchCoord = touchEventCoord(e)
GestureService.beginSwipe(e, this.closeGesture)
},
touchMove (e) {
const delta = deltaCoord(this.touchCoord, touchEventCoord(e))
if (delta[0] < -30 && Math.abs(delta[1]) < Math.abs(delta[0]) && !this.closed) {
this.toggleDrawer()
}
GestureService.updateSwipe(e, this.closeGesture)
}
}
}

View file

@ -2,25 +2,21 @@
<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">
<user-card-content :user="currentUser" :switcher="false" :hideBio="true" v-if="currentUser"/>
<UserCard :user="currentUser" :hideBio="true" v-if="currentUser"/>
<div class="side-drawer-logo-wrapper" v-else>
<img :src="logo"/>
<span>{{sitename}}</span>
</div>
</div>
<ul>
<li v-if="currentUser" @click="toggleDrawer">
<router-link :to="{ name: 'new-status', params: { username: currentUser.screen_name } }">
{{ $t("post_status.new_status") }}
</router-link>
</li>
<li v-else @click="toggleDrawer">
<li v-if="!currentUser" @click="toggleDrawer">
<router-link :to="{ name: 'login' }">
{{ $t("login.login") }}
</router-link>
@ -116,17 +112,33 @@
height: 100%;
display: flex;
align-items: stretch;
transition-duration: 0s;
transition-property: transform;
}
.side-drawer-container-open {
transition-delay: 0.0s;
transition-property: left;
transform: translate(0%);
}
.side-drawer-container-closed {
left: -100%;
transition-delay: 0.5s;
transition-property: left;
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 {
@ -135,8 +147,9 @@
.side-drawer {
overflow-x: hidden;
transition: 0.35s;
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%;
@ -181,15 +194,6 @@
display: flex;
padding: 0;
margin: 0;
.profile-panel-background {
border-radius: 0;
.panel-heading {
background: transparent;
flex-direction: column;
align-items: stretch;
}
}
}
.side-drawer ul {

View file

@ -3,7 +3,7 @@ import FavoriteButton from '../favorite_button/favorite_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue'
import DeleteButton from '../delete_button/delete_button.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCardContent from '../user_card_content/user_card_content.vue'
import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
@ -145,11 +145,11 @@ const Status = {
return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id)
},
replyToName () {
const user = this.$store.state.users.usersObject[this.status.in_reply_to_user_id]
if (user) {
return user.screen_name
} else {
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 () {
@ -259,7 +259,7 @@ const Status = {
RetweetButton,
DeleteButton,
PostStatusForm,
UserCardContent,
UserCard,
UserAvatar,
Gallery,
LinkPreview
@ -310,7 +310,6 @@ const Status = {
this.replying = !this.replying
},
gotoOriginal (id) {
// only handled by conversation, not status_or_conversation
if (this.inConversation) {
this.$emit('goto', id)
}

View file

@ -12,7 +12,7 @@
</div>
</template>
<template v-else>
<div v-if="retweet && !noHeading" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info">
<div v-if="retweet && !noHeading && !inConversation" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info">
<UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/>
<div class="media-body faint">
<span class="user-name">
@ -24,16 +24,14 @@
</div>
</div>
<div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet }]" :style="[ userStyle ]" class="media status">
<div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]" :style="[ userStyle ]" class="media status">
<div v-if="!noHeading" class="media-left">
<router-link :to="userProfileLink" @click.stop.prevent.capture.native="toggleUserExpanded">
<UserAvatar :compact="compact" :betterShadow="betterShadow" :src="status.user.profile_image_url_original"/>
</router-link>
</div>
<div class="status-body">
<div class="usercard" v-if="userExpanded">
<user-card-content :user="status.user" :switcher="false"></user-card-content>
</div>
<UserCard :user="status.user" :rounded="true" :bordered="true" class="status-usercard" v-if="userExpanded"/>
<div v-if="!noHeading" class="media-heading">
<div class="heading-name-row">
<div class="name-and-account-name">
@ -77,13 +75,13 @@
<router-link :to="replyProfileLink">
{{replyToName}}
</router-link>
<span class="faint replies-separator" v-if="replies.length">
<span class="faint replies-separator" v-if="replies && replies.length">
-
</span>
</div>
<div class="replies" v-if="inConversation && !isPreview">
<span class="faint" v-if="replies.length">{{$t('status.replies_list')}}</span>
<span class="reply-link faint" v-for="reply in replies">
<span class="faint" v-if="replies && replies.length">{{$t('status.replies_list')}}</span>
<span class="reply-link faint" v-if="replies" v-for="reply in replies">
<a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}}</a>
</span>
</div>
@ -137,9 +135,8 @@
<div v-if="!noHeading && !isPreview" class='status-actions media-body'>
<div v-if="loggedIn">
<a href="#" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')">
<i class="button-icon icon-reply" :class="{'icon-reply-active': replying}"></i>
</a>
<i class="button-icon icon-reply" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')" :class="{'icon-reply-active': replying}"></i>
<span v-if="status.replies_count > 0">{{status.replies_count}}</span>
</div>
<retweet-button :visibility='status.visibility' :loggedIn='loggedIn' :status='status'></retweet-button>
<favorite-button :loggedIn='loggedIn' :status='status'></favorite-button>
@ -248,8 +245,7 @@ $status-margin: 0.75em;
padding: 0;
}
.usercard {
margin: 0;
.status-usercard {
margin-bottom: $status-margin;
}
@ -422,6 +418,11 @@ $status-margin: 0.75em;
max-height: 400px;
vertical-align: middle;
object-fit: contain;
&.emoji {
width: 32px;
height: 32px;
}
}
blockquote {
@ -549,6 +550,7 @@ $status-margin: 0.75em;
.icon-reply:hover {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
cursor: pointer;
}
.icon-reply.icon-reply-active {

View file

@ -1,22 +0,0 @@
import Status from '../status/status.vue'
import Conversation from '../conversation/conversation.vue'
const statusOrConversation = {
props: ['statusoid'],
data () {
return {
expanded: false
}
},
components: {
Status,
Conversation
},
methods: {
toggleExpanded () {
this.expanded = !this.expanded
}
}
}
export default statusOrConversation

View file

@ -1,14 +0,0 @@
<template>
<div>
<conversation v-if="expanded" @toggleExpanded="toggleExpanded" :collapsable="true" :statusoid="statusoid"></conversation>
<status v-if="!expanded" @toggleExpanded="toggleExpanded" :expandable="true" :inConversation="false" :focused="false" :statusoid="statusoid"></status>
</div>
</template>
<script src="./status_or_conversation.js"></script>
<style lang="scss">
.spacer {
height: 1em
}
</style>

View file

@ -1,6 +1,6 @@
import Status from '../status/status.vue'
import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js'
import StatusOrConversation from '../status_or_conversation/status_or_conversation.vue'
import Conversation from '../conversation/conversation.vue'
import { throttle } from 'lodash'
const Timeline = {
@ -43,7 +43,7 @@ const Timeline = {
},
components: {
Status,
StatusOrConversation
Conversation
},
created () {
const store = this.$store
@ -132,7 +132,9 @@ const Timeline = {
}
if (count > 0) {
// only 'stream' them when you're scrolled to the top
if (window.pageYOffset < 15 &&
const doc = document.documentElement
const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0)
if (top < 15 &&
!this.paused &&
!(this.unfocused && this.$store.state.config.pauseOnUnfocused)
) {

View file

@ -16,7 +16,13 @@
</div>
<div :class="classes.body">
<div class="timeline">
<status-or-conversation v-for="status in timeline.visibleStatuses" :key="status.id" v-bind:statusoid="status" class="status-fadein"></status-or-conversation>
<conversation
v-for="status in timeline.visibleStatuses"
class="status-fadein"
:key="status.id"
:statusoid="status"
:collapsable="true"
/>
</div>
</div>
<div :class="classes.footer">

View file

@ -23,6 +23,11 @@ const UserAvatar = {
imageLoadError () {
this.showPlaceholder = true
}
},
watch: {
src () {
this.showPlaceholder = false
}
}
}

View file

@ -1,10 +1,11 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
export default {
props: [ 'user', 'switcher', 'selected', 'hideBio' ],
props: [ 'user', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered' ],
data () {
return {
followRequestInProgress: false,
@ -15,8 +16,18 @@ export default {
betterShadow: this.$store.state.interface.browserSupport.cssFilter
}
},
created () {
this.$store.dispatch('fetchUserRelationship', this.user.id)
},
computed: {
headingStyle () {
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 () {
const color = this.$store.state.config.customTheme.colors
? this.$store.state.config.customTheme.colors.bg // v2
: this.$store.state.config.colors.bg // v1
@ -89,36 +100,37 @@ export default {
}
},
components: {
UserAvatar
UserAvatar,
RemoteFollow
},
methods: {
followUser () {
const store = this.$store
this.followRequestInProgress = true
requestFollow(this.user, this.$store).then(({sent}) => {
requestFollow(this.user, store).then(({sent}) => {
this.followRequestInProgress = false
this.followRequestSent = sent
})
},
unfollowUser () {
const store = this.$store
this.followRequestInProgress = true
requestUnfollow(this.user, this.$store).then(() => {
requestUnfollow(this.user, store).then(() => {
this.followRequestInProgress = false
store.commit('removeStatus', { timeline: 'friends', userId: this.user.id })
})
},
blockUser () {
const store = this.$store
store.state.api.backendInteractor.blockUser(this.user.id)
.then((blockedUser) => store.commit('addNewUsers', [blockedUser]))
this.$store.dispatch('blockUser', this.user.id)
},
unblockUser () {
const store = this.$store
store.state.api.backendInteractor.unblockUser(this.user.id)
.then((unblockedUser) => store.commit('addNewUsers', [unblockedUser]))
this.$store.dispatch('unblockUser', this.user.id)
},
toggleMute () {
const store = this.$store
store.commit('setMuted', {user: this.user, muted: !this.user.muted})
store.state.api.backendInteractor.setUserMute(this.user)
muteUser () {
this.$store.dispatch('muteUser', this.user.id)
},
unmuteUser () {
this.$store.dispatch('unmuteUser', this.user.id)
},
setProfileView (v) {
if (this.switcher) {

View file

@ -1,6 +1,6 @@
<template>
<div id="heading" class="profile-panel-background" :style="headingStyle">
<div class="panel-heading text-center">
<div class="user-card" :class="classes" :style="style">
<div class="panel-heading">
<div class='user-info'>
<div class='container'>
<router-link :to="userProfileLink(user)">
@ -11,7 +11,7 @@
<div :title="user.name" class='user-name' v-if="user.name_html" v-html="user.name_html"></div>
<div :title="user.name" class='user-name' v-else>{{user.name}}</div>
<router-link :to="{ name: 'user-settings' }" v-if="!isOtherUser">
<i class="button-icon icon-cog usersettings" :title="$t('tool_tip.user_settings')"></i>
<i class="button-icon icon-pencil usersettings" :title="$t('tool_tip.user_settings')"></i>
</router-link>
<a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser && !user.is_local">
<i class="icon-link-ext usersettings"></i>
@ -74,24 +74,18 @@
</div>
<div class='mute' v-if='isOtherUser && loggedIn'>
<span v-if='user.muted'>
<button @click="toggleMute" class="pressed">
<button @click="unmuteUser" class="pressed">
{{ $t('user_card.muted') }}
</button>
</span>
<span v-if='!user.muted'>
<button @click="toggleMute">
<button @click="muteUser">
{{ $t('user_card.mute') }}
</button>
</span>
</div>
<div class="remote-follow" v-if='!loggedIn && user.is_local'>
<form method="POST" :action='subscribeUrl'>
<input type="hidden" name="nickname" :value="user.screen_name">
<input type="hidden" name="profile" value="">
<button click="submit" class="remote-button">
{{ $t('user_card.remote_follow') }}
</button>
</form>
<div v-if='!loggedIn && user.is_local'>
<RemoteFollow :user="user" />
</div>
<div class='block' v-if='isOtherUser && loggedIn'>
<span v-if='user.statusnet_blocking'>
@ -108,7 +102,7 @@
</div>
</div>
</div>
<div class="panel-body profile-panel-body" v-if="!hideBio">
<div class="panel-body" v-if="!hideBio">
<div v-if="!hideUserStatsLocal && switcher" class="user-counts">
<div class="user-count" v-on:click.prevent="setProfileView('statuses')">
<h5>{{ $t('user_card.statuses') }}</h5>
@ -123,40 +117,75 @@
<span>{{user.followers_count}}</span>
</div>
</div>
<p @click.prevent="linkClicked" v-if="!hideBio && user.description_html" class="profile-bio" v-html="user.description_html"></p>
<p v-else-if="!hideBio" class="profile-bio">{{ user.description }}</p>
<p @click.prevent="linkClicked" v-if="!hideBio && user.description_html" class="user-card-bio" v-html="user.description_html"></p>
<p v-else-if="!hideBio" class="user-card-bio">{{ user.description }}</p>
</div>
</div>
</template>
<script src="./user_card_content.js"></script>
<script src="./user_card.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.profile-panel-background {
.user-card {
background-size: cover;
border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius);
overflow: hidden;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
.panel-heading {
padding: .5em 0;
text-align: center;
box-shadow: none;
background: transparent;
flex-direction: column;
align-items: stretch;
}
}
.profile-panel-body {
word-wrap: break-word;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%);
.panel-body {
word-wrap: break-word;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%);
}
.profile-bio {
p {
margin-bottom: 0;
}
&-bio {
text-align: center;
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);
}
}
@ -340,11 +369,6 @@
min-height: 28px;
}
.remote-follow {
max-width: 220px;
min-height: 28px;
}
.follow {
max-width: 220px;
min-height: 28px;
@ -393,25 +417,4 @@
text-decoration: none;
}
}
.usercard {
width: fill-available;
border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius);
border-style: solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
border-width: 1px;
overflow: hidden;
.panel-heading {
background: transparent;
flex-direction: column;
align-items: stretch;
}
p {
margin-bottom: 0;
}
}
</style>

View file

@ -1,6 +1,6 @@
import LoginForm from '../login_form/login_form.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCardContent from '../user_card_content/user_card_content.vue'
import UserCard from '../user_card/user_card.vue'
const UserPanel = {
computed: {
@ -9,7 +9,7 @@ const UserPanel = {
components: {
LoginForm,
PostStatusForm,
UserCardContent
UserCard
}
}

View file

@ -1,7 +1,7 @@
<template>
<div class="user-panel">
<div v-if='user' class="panel panel-default" style="overflow: visible;">
<user-card-content :user="user" :switcher="false" :hideBio="true"></user-card-content>
<UserCard :user="user" :hideBio="true" rounded="top"/>
<div class="panel-footer">
<post-status-form v-if='user'></post-status-form>
</div>
@ -11,13 +11,3 @@
</template>
<script src="./user_panel.js"></script>
<style lang="scss">
.user-panel {
.profile-panel-background .panel-heading {
background: transparent;
flex-direction: column;
align-items: stretch;
}
}
</style>

View file

@ -1,6 +1,6 @@
import { compose } from 'vue-compose'
import get from 'lodash/get'
import UserCardContent from '../user_card_content/user_card_content.vue'
import UserCard from '../user_card/user_card.vue'
import FollowCard from '../follow_card/follow_card.vue'
import Timeline from '../timeline/timeline.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
@ -9,7 +9,7 @@ import withList from '../../hocs/with_list/with_list'
const FollowerList = compose(
withLoadMore({
fetch: (props, $store) => $store.dispatch('addFollowers', props.userId),
select: (props, $store) => get($store.getters.userById(props.userId), 'followers', []),
select: (props, $store) => get($store.getters.findUser(props.userId), 'followers', []),
destory: (props, $store) => $store.dispatch('clearFollowers', props.userId),
childPropName: 'entries',
additionalPropNames: ['userId']
@ -20,7 +20,7 @@ const FollowerList = compose(
const FriendList = compose(
withLoadMore({
fetch: (props, $store) => $store.dispatch('addFriends', props.userId),
select: (props, $store) => get($store.getters.userById(props.userId), 'friends', []),
select: (props, $store) => get($store.getters.findUser(props.userId), 'friends', []),
destory: (props, $store) => $store.dispatch('clearFriends', props.userId),
childPropName: 'entries',
additionalPropNames: ['userId']
@ -31,28 +31,16 @@ const FriendList = compose(
const UserProfile = {
data () {
return {
error: false
error: false,
fetchedUserId: null
}
},
created () {
this.$store.commit('clearTimeline', { timeline: 'user' })
this.$store.commit('clearTimeline', { timeline: 'favorites' })
this.$store.commit('clearTimeline', { timeline: 'media' })
this.$store.dispatch('startFetching', { timeline: 'user', userId: this.fetchBy })
this.$store.dispatch('startFetching', { timeline: 'media', userId: this.fetchBy })
this.startFetchFavorites()
if (!this.user.id) {
this.$store.dispatch('fetchUser', this.fetchBy)
.catch((reason) => {
const errorMessage = get(reason, 'error.error')
if (errorMessage === 'No user with such user_id') { // Known error
this.error = this.$t('user_profile.profile_does_not_exist')
} else if (errorMessage) {
this.error = errorMessage
} else {
this.error = this.$t('user_profile.profile_loading_error')
}
})
this.fetchUserId()
.then(() => this.startUp())
} else {
this.startUp()
}
},
destroyed () {
@ -69,7 +57,7 @@ const UserProfile = {
return this.$store.state.statuses.timelines.media
},
userId () {
return this.$route.params.id || this.user.id
return this.$route.params.id || this.user.id || this.fetchedUserId
},
userName () {
return this.$route.params.name || this.user.screen_name
@ -79,10 +67,9 @@ const UserProfile = {
this.userId === this.$store.state.users.currentUser.id
},
userInStore () {
if (this.isExternal) {
return this.$store.getters.userById(this.userId)
}
return this.$store.getters.userByName(this.userName)
const routeParams = this.$route.params
// This needs fetchedUserId so that computed will be refreshed when user is fetched
return this.$store.getters.findUser(this.fetchedUserId || routeParams.name || routeParams.id)
},
user () {
if (this.timeline.statuses[0]) {
@ -93,9 +80,6 @@ const UserProfile = {
}
return {}
},
fetchBy () {
return this.isExternal ? this.userId : this.userName
},
isExternal () {
return this.$route.name === 'external-user-profile'
},
@ -109,14 +93,38 @@ const UserProfile = {
methods: {
startFetchFavorites () {
if (this.isUs) {
this.$store.dispatch('startFetching', { timeline: 'favorites', userId: this.fetchBy })
this.$store.dispatch('startFetching', { timeline: 'favorites', userId: this.userId })
}
},
fetchUserId () {
let fetchPromise
if (this.userId && !this.$route.params.name) {
fetchPromise = this.$store.dispatch('fetchUser', this.userId)
} else {
fetchPromise = this.$store.dispatch('fetchUser', this.userName)
.then(({ id }) => {
this.fetchedUserId = id
})
}
return fetchPromise
.catch((reason) => {
const errorMessage = get(reason, 'error.error')
if (errorMessage === 'No user with such user_id') { // Known error
this.error = this.$t('user_profile.profile_does_not_exist')
} else if (errorMessage) {
this.error = errorMessage
} else {
this.error = this.$t('user_profile.profile_loading_error')
}
})
.then(() => this.startUp())
},
startUp () {
this.$store.dispatch('startFetching', { timeline: 'user', userId: this.fetchBy })
this.$store.dispatch('startFetching', { timeline: 'media', userId: this.fetchBy })
this.startFetchFavorites()
if (this.userId) {
this.$store.dispatch('startFetching', { timeline: 'user', userId: this.userId })
this.$store.dispatch('startFetching', { timeline: 'media', userId: this.userId })
this.startFetchFavorites()
}
},
cleanUp () {
this.$store.dispatch('stopFetching', 'user')
@ -128,23 +136,26 @@ const UserProfile = {
}
},
watch: {
userName () {
if (this.isExternal) {
return
// userId can be undefined if we don't know it yet
userId (newVal) {
if (newVal) {
this.cleanUp()
this.startUp()
}
this.cleanUp()
this.startUp()
},
userId () {
if (!this.isExternal) {
return
userName () {
if (this.$route.params.name) {
this.fetchUserId()
this.cleanUp()
this.startUp()
}
this.cleanUp()
this.startUp()
},
$route () {
this.$refs.tabSwitcher.activateTab(0)()
}
},
components: {
UserCardContent,
UserCard,
Timeline,
FollowerList,
FriendList

View file

@ -1,12 +1,8 @@
<template>
<div>
<div v-if="user.id" class="user-profile panel panel-default">
<user-card-content
:user="user"
:switcher="true"
:selected="timeline.viewing"
/>
<tab-switcher :renderOnlyFocused="true">
<UserCard :user="user" :switcher="true" :selected="timeline.viewing" rounded="top"/>
<tab-switcher :renderOnlyFocused="true" ref="tabSwitcher">
<Timeline
:label="$t('user_card.statuses')"
:disabled="!user.statuses_count"
@ -15,7 +11,7 @@
:title="$t('user_profile.timeline_title')"
:timeline="timeline"
:timeline-name="'user'"
:user-id="fetchBy"
:user-id="userId"
/>
<div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count">
<FriendList :userId="userId" />
@ -29,7 +25,7 @@
:embedded="true" :title="$t('user_card.media')"
timeline-name="media"
:timeline="media"
:user-id="fetchBy"
:user-id="userId"
/>
<Timeline
v-if="isUs"
@ -64,11 +60,6 @@
flex: 2;
flex-basis: 500px;
.profile-panel-background .panel-heading {
background: transparent;
flex-direction: column;
align-items: stretch;
}
.userlist-placeholder {
display: flex;
justify-content: center;

View file

@ -8,6 +8,7 @@ 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 EmojiInput from '../emoji-input/emoji-input.vue'
import withSubscription from '../../hocs/with_subscription/with_subscription'
import withList from '../../hocs/with_list/with_list'
@ -71,7 +72,8 @@ const UserSettings = {
TabSwitcher,
ImageCropper,
BlockList,
MuteList
MuteList,
EmojiInput
},
computed: {
user () {
@ -159,8 +161,14 @@ const UserSettings = {
}
reader.readAsDataURL(file)
},
submitAvatar (cropper) {
const img = cropper.getCroppedCanvas().toDataURL('image/jpeg')
submitAvatar (cropper, file) {
let img
if (cropper) {
img = cropper.getCroppedCanvas().toDataURL(file.type)
} else {
img = file
}
return this.$store.state.api.backendInteractor.updateAvatar({ params: { img } }).then((user) => {
if (!user.error) {
this.$store.commit('addNewUsers', [user])

View file

@ -22,9 +22,18 @@
<div class="setting-item" >
<h2>{{$t('settings.name_bio')}}</h2>
<p>{{$t('settings.name')}}</p>
<input class='name-changer' id='username' v-model="newName"></input>
<EmojiInput
type="text"
v-model="newName"
id="username"
classname="name-changer"
/>
<p>{{$t('settings.bio')}}</p>
<textarea class="bio" v-model="newBio"></textarea>
<EmojiInput
type="textarea"
v-model="newBio"
classname="bio"
/>
<p>
<input type="checkbox" v-model="newLocked" id="account-locked">
<label for="account-locked">{{$t('settings.lock_account_description')}}</label>
@ -61,7 +70,7 @@
<h2>{{$t('settings.avatar')}}</h2>
<p class="visibility-notice">{{$t('settings.avatar_size_instruction')}}</p>
<p>{{$t('settings.current_avatar')}}</p>
<img :src="user.profile_image_url_original" class="current-avatar"></img>
<img :src="user.profile_image_url_original" class="current-avatar" />
<p>{{$t('settings.set_new_avatar')}}</p>
<button class="btn" type="button" id="pick-avatar" v-show="pickAvatarBtnVisible">{{$t('settings.upload_a_photo')}}</button>
<image-cropper trigger="#pick-avatar" :submitHandler="submitAvatar" @open="pickAvatarBtnVisible=false" @close="pickAvatarBtnVisible=true" />
@ -69,12 +78,11 @@
<div class="setting-item">
<h2>{{$t('settings.profile_banner')}}</h2>
<p>{{$t('settings.current_profile_banner')}}</p>
<img :src="user.cover_photo" class="banner"></img>
<img :src="user.cover_photo" class="banner" />
<p>{{$t('settings.set_new_profile_banner')}}</p>
<img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview">
</img>
<img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview" />
<div>
<input type="file" @change="uploadFile('banner', $event)" ></input>
<input type="file" @change="uploadFile('banner', $event)" />
</div>
<i class=" icon-spin4 animate-spin uploading" v-if="bannerUploading"></i>
<button class="btn btn-default" v-else-if="bannerPreview" @click="submitBanner">{{$t('general.submit')}}</button>
@ -86,10 +94,9 @@
<div class="setting-item">
<h2>{{$t('settings.profile_background')}}</h2>
<p>{{$t('settings.set_new_profile_background')}}</p>
<img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview">
</img>
<img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview" />
<div>
<input type="file" @change="uploadFile('background', $event)" ></input>
<input type="file" @change="uploadFile('background', $event)" />
</div>
<i class=" icon-spin4 animate-spin uploading" v-if="backgroundUploading"></i>
<button class="btn btn-default" v-else-if="backgroundPreview" @click="submitBg">{{$t('general.submit')}}</button>
@ -165,7 +172,7 @@
<h2>{{$t('settings.follow_import')}}</h2>
<p>{{$t('settings.import_followers_from_a_csv_file')}}</p>
<form>
<input type="file" ref="followlist" v-on:change="followListChange"></input>
<input type="file" ref="followlist" v-on:change="followListChange" />
</form>
<i class=" icon-spin4 animate-spin uploading" v-if="followListUploading"></i>
<button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button>
@ -192,6 +199,12 @@
<template slot="empty">{{$t('settings.no_blocks')}}</template>
</block-list>
</div>
<div :label="$t('settings.mutes_tab')">
<mute-list :refresh="true">
<template slot="empty">{{$t('settings.no_mutes')}}</template>
</mute-list>
</div>
</tab-switcher>
</div>
</div>