Merge remote-tracking branch 'upstream/develop' into shigusegubu
* upstream/develop: (83 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:
commit
b3efaecaaa
45 changed files with 931 additions and 401 deletions
|
|
@ -8,8 +8,8 @@
|
|||
</div>
|
||||
<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" v-html="user.name_html"></span>
|
||||
<span v-else>{{ user.name }}</span>
|
||||
<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="basic-user-card-screen-name" :to="userProfileLink(user)">
|
||||
|
|
@ -52,6 +52,14 @@
|
|||
width: 16px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&-value {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
&-expanded-content {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
107
src/components/emoji-input/emoji-input.js
Normal file
107
src/components/emoji-input/emoji-input.js
Normal 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
|
||||
64
src/components/emoji-input/emoji-input.vue
Normal file
64
src/components/emoji-input/emoji-input.vue
Normal 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>
|
||||
|
|
@ -4,12 +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="btn-follow" v-if="showFollow && !loggedIn">
|
||||
<div class="follow-card-follow-button" v-if="showFollow && !loggedIn">
|
||||
<RemoteFollow :user="user" />
|
||||
</div>
|
||||
<button
|
||||
v-if="showFollow && loggedIn"
|
||||
class="btn btn-default btn-follow"
|
||||
class="btn btn-default follow-card-follow-button"
|
||||
@click="followUser"
|
||||
:disabled="inProgress"
|
||||
:title="requestSent ? $t('user_card.follow_again') : ''"
|
||||
|
|
@ -24,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>
|
||||
|
|
@ -39,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 {
|
||||
&-follow-button {
|
||||
margin-top: 0.5em;
|
||||
margin-left: auto;
|
||||
width: 10em;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -31,6 +32,7 @@ const PostStatusForm = {
|
|||
components: {
|
||||
MediaUpload,
|
||||
ScopeSelector
|
||||
EmojiInput
|
||||
},
|
||||
mounted () {
|
||||
this.resize(this.$refs.textarea)
|
||||
|
|
@ -297,6 +299,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.
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@
|
|||
<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"
|
||||
|
|
@ -56,14 +57,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>
|
||||
|
|
@ -85,10 +90,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>
|
||||
|
|
@ -262,52 +267,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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }">
|
||||
|
|
|
|||
|
|
@ -47,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,
|
||||
|
|
@ -182,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 })
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
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
|
||||
}),
|
||||
created () {
|
||||
this.closeGesture = GestureService.swipeGesture(GestureService.DIRECTION_LEFT, this.toggleDrawer)
|
||||
},
|
||||
components: { UserCard },
|
||||
computed: {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<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"
|
||||
|
|
@ -111,16 +112,32 @@
|
|||
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-container-closed {
|
||||
left: -100%;
|
||||
.side-drawer-darken-closed {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
|
|
@ -130,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%;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,7 +24,7 @@
|
|||
</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"/>
|
||||
|
|
@ -135,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>
|
||||
|
|
@ -551,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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -121,24 +121,16 @@ export default {
|
|||
})
|
||||
},
|
||||
blockUser () {
|
||||
const store = this.$store
|
||||
store.state.api.backendInteractor.blockUser(this.user.id)
|
||||
.then((blockedUser) => {
|
||||
store.commit('addNewUsers', [blockedUser])
|
||||
store.commit('removeStatus', { timeline: 'friends', userId: this.user.id })
|
||||
store.commit('removeStatus', { timeline: 'public', userId: this.user.id })
|
||||
store.commit('removeStatus', { timeline: 'publicAndExternal', userId: this.user.id })
|
||||
})
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -74,12 +74,12 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue