* upstream/develop: (469 commits) Feature/add sticker picker guard more secure routes guard secure routes by redirecting to root closest can returns itself as well find inside status-content div only try to use the closest a tag as target Update es.json Also apply keyword filter to subjects Remove files I accidentally pushed in fix issues caused by merges in usersearch on @ Add user search at fix eslint warnings remove vue-popperjs fix moderation menu partially hidden by usercard boundary migrate popper css rewrite ModerationTools using v-tooltip make popover position for status action dropdow relative to parent node rewrite ExtraButtons using v-tooltip install v-tooltip i18n/Update pedantic Japanese translation ...emoji-selector-update
commit
b3aff9bbae
@ -0,0 +1,26 @@
|
||||
import LoginForm from '../login_form/login_form.vue'
|
||||
import MFARecoveryForm from '../mfa_form/recovery_form.vue'
|
||||
import MFATOTPForm from '../mfa_form/totp_form.vue'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
const AuthForm = {
|
||||
name: 'AuthForm',
|
||||
render (createElement) {
|
||||
return createElement('component', { is: this.authForm })
|
||||
},
|
||||
computed: {
|
||||
authForm () {
|
||||
if (this.requiredTOTP) { return 'MFATOTPForm' }
|
||||
if (this.requiredRecovery) { return 'MFARecoveryForm' }
|
||||
return 'LoginForm'
|
||||
},
|
||||
...mapGetters('authFlow', ['requiredTOTP', 'requiredRecovery'])
|
||||
},
|
||||
components: {
|
||||
MFARecoveryForm,
|
||||
MFATOTPForm,
|
||||
LoginForm
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthForm
|
@ -0,0 +1,52 @@
|
||||
const debounceMilliseconds = 500
|
||||
|
||||
export default {
|
||||
props: {
|
||||
query: { // function to query results and return a promise
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
filter: { // function to filter results in real time
|
||||
type: Function
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Search...'
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
term: '',
|
||||
timeout: null,
|
||||
results: [],
|
||||
resultsVisible: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filtered () {
|
||||
return this.filter ? this.filter(this.results) : this.results
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
term (val) {
|
||||
this.fetchResults(val)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchResults (term) {
|
||||
clearTimeout(this.timeout)
|
||||
this.timeout = setTimeout(() => {
|
||||
this.results = []
|
||||
if (term) {
|
||||
this.query(term).then((results) => { this.results = results })
|
||||
}
|
||||
}, debounceMilliseconds)
|
||||
},
|
||||
onInputClick () {
|
||||
this.resultsVisible = true
|
||||
},
|
||||
onClickOutside () {
|
||||
this.resultsVisible = false
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div
|
||||
v-click-outside="onClickOutside"
|
||||
class="autosuggest"
|
||||
>
|
||||
<input
|
||||
v-model="term"
|
||||
:placeholder="placeholder"
|
||||
class="autosuggest-input"
|
||||
@click="onInputClick"
|
||||
>
|
||||
<div
|
||||
v-if="resultsVisible && filtered.length > 0"
|
||||
class="autosuggest-results"
|
||||
>
|
||||
<slot
|
||||
v-for="item in filtered"
|
||||
:item="item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./autosuggest.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.autosuggest {
|
||||
position: relative;
|
||||
|
||||
&-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-results {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
max-height: 400px;
|
||||
background-color: $fallback--lightBg;
|
||||
background-color: var(--lightBg, $fallback--lightBg);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-color: $fallback--border;
|
||||
border-color: var(--border, $fallback--border);
|
||||
border-radius: $fallback--inputRadius;
|
||||
border-radius: var(--inputRadius, $fallback--inputRadius);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
|
||||
box-shadow: var(--panelShadow);
|
||||
overflow-y: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,21 @@
|
||||
import UserAvatar from '../user_avatar/user_avatar.vue'
|
||||
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
|
||||
|
||||
const AvatarList = {
|
||||
props: ['users'],
|
||||
computed: {
|
||||
slicedUsers () {
|
||||
return this.users ? this.users.slice(0, 15) : []
|
||||
}
|
||||
},
|
||||
components: {
|
||||
UserAvatar
|
||||
},
|
||||
methods: {
|
||||
userProfileLink (user) {
|
||||
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AvatarList
|
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="avatars">
|
||||
<router-link
|
||||
v-for="user in slicedUsers"
|
||||
:key="user.id"
|
||||
:to="userProfileLink(user)"
|
||||
class="avatars-item"
|
||||
>
|
||||
<UserAvatar
|
||||
:user="user"
|
||||
class="avatar-small"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./avatar_list.js" ></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.avatars {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
// For hiding overflowing elements
|
||||
flex-wrap: wrap;
|
||||
height: 24px;
|
||||
|
||||
.avatars-item {
|
||||
margin: 0 0 5px 5px;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.avatar-small {
|
||||
border-radius: $fallback--avatarAltRadius;
|
||||
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<conversation
|
||||
:collapsable="false"
|
||||
isPage="true"
|
||||
is-page="true"
|
||||
:statusoid="statusoid"
|
||||
></conversation>
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script src="./conversation-page.js"></script>
|
||||
|
@ -1,17 +0,0 @@
|
||||
const DeleteButton = {
|
||||
props: [ 'status' ],
|
||||
methods: {
|
||||
deleteStatus () {
|
||||
const confirmed = window.confirm('Do you really want to delete this status?')
|
||||
if (confirmed) {
|
||||
this.$store.dispatch('deleteStatus', { id: this.status.id })
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentUser () { return this.$store.state.users.currentUser },
|
||||
canDelete () { return this.currentUser && this.currentUser.rights.delete_others_notice || this.status.user.id === this.currentUser.id }
|
||||
}
|
||||
}
|
||||
|
||||
export default DeleteButton
|
@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<div v-if="canDelete">
|
||||
<a href="#" v-on:click.prevent="deleteStatus()">
|
||||
<i class='button-icon icon-cancel delete-status'></i>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./delete_button.js" ></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.icon-cancel,.delete-status {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: $fallback--cRed;
|
||||
color: var(--cRed, $fallback--cRed);
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,14 @@
|
||||
const DialogModal = {
|
||||
props: {
|
||||
darkOverlay: {
|
||||
default: true,
|
||||
type: Boolean
|
||||
},
|
||||
onCancel: {
|
||||
default: () => {},
|
||||
type: Function
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DialogModal
|
@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<span
|
||||
:class="{ 'dark-overlay': darkOverlay }"
|
||||
@click.self.stop="onCancel()"
|
||||
>
|
||||
<div
|
||||
class="dialog-modal panel panel-default"
|
||||
@click.stop=""
|
||||
>
|
||||
<div class="panel-heading dialog-modal-heading">
|
||||
<div class="title">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-modal-content">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
<div class="dialog-modal-footer user-interactions panel-footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script src="./dialog_modal.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
// TODO: unify with other modals.
|
||||
.dark-overlay {
|
||||
&::before {
|
||||
bottom: 0;
|
||||
content: " ";
|
||||
display: block;
|
||||
cursor: default;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
background: rgba(27,31,35,.5);
|
||||
z-index: 99;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-modal.panel {
|
||||
top: 0;
|
||||
left: 50%;
|
||||
max-height: 80vh;
|
||||
max-width: 90vw;
|
||||
margin: 15vh auto;
|
||||
position: fixed;
|
||||
transform: translateX(-50%);
|
||||
z-index: 999;
|
||||
cursor: default;
|
||||
display: block;
|
||||
background-color: $fallback--bg;
|
||||
background-color: var(--bg, $fallback--bg);
|
||||
|
||||
.dialog-modal-heading {
|
||||
padding: .5em .5em;
|
||||
margin-right: auto;
|
||||
margin-bottom: 0;
|
||||
white-space: nowrap;
|
||||
color: var(--panelText);
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--panel, $fallback--fg);
|
||||
|
||||
.title {
|
||||
margin-bottom: 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-modal-content {
|
||||
margin: 0;
|
||||
padding: 1rem 1rem;
|
||||
background-color: $fallback--lightBg;
|
||||
background-color: var(--lightBg, $fallback--lightBg);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.dialog-modal-footer {
|
||||
margin: 0;
|
||||
padding: .5em .5em;
|
||||
background-color: $fallback--lightBg;
|
||||
background-color: var(--lightBg, $fallback--lightBg);
|
||||
border-top: 1px solid $fallback--bg;
|
||||
border-top: 1px solid var(--bg, $fallback--bg);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
button {
|
||||
width: auto;
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<Timeline :title="$t('nav.dms')" v-bind:timeline="timeline" v-bind:timeline-name="'dms'"/>
|
||||
<Timeline
|
||||
:title="$t('nav.dms')"
|
||||
:timeline="timeline"
|
||||
:timeline-name="'dms'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script src="./dm_timeline.js"></script>
|
||||
|
@ -0,0 +1,94 @@
|
||||
import { debounce } from 'lodash'
|
||||
/**
|
||||
* suggest - generates a suggestor function to be used by emoji-input
|
||||
* data: object providing source information for specific types of suggestions:
|
||||
* data.emoji - optional, an array of all emoji available i.e.
|
||||
* (state.instance.emoji + state.instance.customEmoji)
|
||||
* data.users - optional, an array of all known users
|
||||
* updateUsersList - optional, a function to search and append to users
|
||||
*
|
||||
* Depending on data present one or both (or none) can be present, so if field
|
||||
* doesn't support user linking you can just provide only emoji.
|
||||
*/
|
||||
|
||||
const debounceUserSearch = debounce((data, input) => {
|
||||
data.updateUsersList(input)
|
||||
}, 500, { leading: true, trailing: false })
|
||||
|
||||
export default data => input => {
|
||||
const firstChar = input[0]
|
||||
if (firstChar === ':' && data.emoji) {
|
||||
return suggestEmoji(data.emoji)(input)
|
||||
}
|
||||
if (firstChar === '@' && data.users) {
|
||||
return suggestUsers(data)(input)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export const suggestEmoji = emojis => input => {
|
||||
const noPrefix = input.toLowerCase().substr(1)
|
||||
return emojis
|
||||
.filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix))
|
||||
.sort((a, b) => {
|
||||
let aScore = 0
|
||||
let bScore = 0
|
||||
|
||||
// Make custom emojis a priority
|
||||
aScore += a.imageUrl ? 10 : 0
|
||||
bScore += b.imageUrl ? 10 : 0
|
||||
|
||||
// Sort alphabetically
|
||||
const alphabetically = a.displayText > b.displayText ? 1 : -1
|
||||
|
||||
return bScore - aScore + alphabetically
|
||||
})
|
||||
}
|
||||
|
||||
export const suggestUsers = data => input => {
|
||||
const noPrefix = input.toLowerCase().substr(1)
|
||||
const users = data.users
|
||||
|
||||
const newUsers = users.filter(
|
||||
user =>
|
||||
user.screen_name.toLowerCase().startsWith(noPrefix) ||
|
||||
user.name.toLowerCase().startsWith(noPrefix)
|
||||
|
||||
/* taking only 20 results so that sorting is a bit cheaper, we display
|
||||
* only 5 anyway. could be inaccurate, but we ideally we should query
|
||||
* backend anyway
|
||||
*/
|
||||
).slice(0, 20).sort((a, b) => {
|
||||
let aScore = 0
|
||||
let bScore = 0
|
||||
|
||||
// Matches on screen name (i.e. user@instance) makes a priority
|
||||
aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
|
||||
bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
|
||||
|
||||
// Matches on name takes second priority
|
||||
aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
|
||||
bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
|
||||
|
||||
const diff = (bScore - aScore) * 10
|
||||
|
||||
// Then sort alphabetically
|
||||
const nameAlphabetically = a.name > b.name ? 1 : -1
|
||||
const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1
|
||||
|
||||
return diff + nameAlphabetically + screenNameAlphabetically
|
||||
/* eslint-disable camelcase */
|
||||
}).map(({ screen_name, name, profile_image_url_original }) => ({
|
||||
displayText: screen_name,
|
||||
detailText: name,
|
||||
imageUrl: profile_image_url_original,
|
||||
replacement: '@' + screen_name + ' '
|
||||
}))
|
||||
|
||||
// BE search users if there are no matches
|
||||
if (newUsers.length === 0 && data.updateUsersList) {
|
||||
debounceUserSearch(data, noPrefix)
|
||||
}
|
||||
return newUsers
|
||||
/* eslint-enable camelcase */
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
const Exporter = {
|
||||
props: {
|
||||
getContent: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
filename: {
|
||||
type: String,
|
||||
default: 'export.csv'
|
||||
},
|
||||
exportButtonLabel: {
|
||||
type: String,
|
||||
default () {
|
||||
return this.$t('exporter.export')
|
||||
}
|
||||
},
|
||||
processingMessage: {
|
||||
type: String,
|
||||
default () {
|
||||
return this.$t('exporter.processing')
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
process () {
|
||||
this.processing = true
|
||||
this.getContent()
|
||||
.then((content) => {
|
||||
const fileToDownload = document.createElement('a')
|
||||
fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(content))
|
||||
fileToDownload.setAttribute('download', this.filename)
|
||||
fileToDownload.style.display = 'none'
|
||||
document.body.appendChild(fileToDownload)
|
||||
fileToDownload.click()
|
||||
document.body.removeChild(fileToDownload)
|
||||
// Add delay before hiding processing state since browser takes some time to handle file download
|
||||
setTimeout(() => { this.processing = false }, 2000)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Exporter
|
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="exporter">
|
||||
<div v-if="processing">
|
||||
<i class="icon-spin4 animate-spin exporter-processing" />
|
||||
<span>{{ processingMessage }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-default"
|
||||
@click="process"
|
||||
>
|
||||
{{ exportButtonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./exporter.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
.exporter {
|
||||
&-processing {
|
||||
font-size: 1.5em;
|
||||
margin: 0.25em;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,40 @@
|
||||
const ExtraButtons = {
|
||||
props: [ 'status' ],
|
||||
methods: {
|
||||
deleteStatus () {
|
||||
const confirmed = window.confirm(this.$t('status.delete_confirm'))
|
||||
if (confirmed) {
|
||||
this.$store.dispatch('deleteStatus', { id: this.status.id })
|
||||
}
|
||||
},
|
||||
pinStatus () {
|
||||
this.$store.dispatch('pinStatus', this.status.id)
|
||||
.then(() => this.$emit('onSuccess'))
|
||||
.catch(err => this.$emit('onError', err.error.error))
|
||||
},
|
||||
unpinStatus () {
|
||||
this.$store.dispatch('unpinStatus', this.status.id)
|
||||
.then(() => this.$emit('onSuccess'))
|
||||
.catch(err => this.$emit('onError', err.error.error))
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentUser () { return this.$store.state.users.currentUser },
|
||||
canDelete () {
|
||||
if (!this.currentUser) { return }
|
||||
const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin
|
||||
return superuser || this.status.user.id === this.currentUser.id
|
||||
},
|
||||
ownStatus () {
|
||||
return this.status.user.id === this.currentUser.id
|
||||
},
|
||||
canPin () {
|
||||
return this.ownStatus && (this.status.visibility === 'public' || this.status.visibility === 'unlisted')
|
||||
},
|
||||
enabled () {
|
||||
return this.canPin || this.canDelete
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ExtraButtons
|
@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<v-popover
|
||||
v-if="enabled"
|
||||
trigger="click"
|
||||
placement="top"
|
||||
class="extra-button-popover"
|
||||
:offset="5"
|
||||
:container="false"
|
||||
>
|
||||
<div slot="popover">
|
||||
<div class="dropdown-menu">
|
||||
<button
|
||||
v-if="!status.pinned && canPin"
|
||||
v-close-popover
|
||||
class="dropdown-item dropdown-item-icon"
|
||||
@click.prevent="pinStatus"
|
||||
>
|
||||
<i class="icon-pin" /><span>{{ $t("status.pin") }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="status.pinned && canPin"
|
||||
v-close-popover
|
||||
class="dropdown-item dropdown-item-icon"
|
||||
@click.prevent="unpinStatus"
|
||||
>
|
||||
<i class="icon-pin" /><span>{{ $t("status.unpin") }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canDelete"
|
||||
v-close-popover
|
||||
class="dropdown-item dropdown-item-icon"
|
||||
@click.prevent="deleteStatus"
|
||||
>
|
||||
<i class="icon-cancel" /><span>{{ $t("status.delete") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-icon">
|
||||
<i class="icon-ellipsis" />
|
||||
</div>
|
||||
</v-popover>
|
||||
</template>
|
||||
|
||||
<script src="./extra_buttons.js" ></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
@import '../popper/popper.scss';
|
||||
|
||||
.icon-ellipsis {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
.extra-button-popover.open & {
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<Timeline :title="$t('nav.timeline')" v-bind:timeline="timeline" v-bind:timeline-name="'friends'"/>
|
||||
<Timeline
|
||||
:title="$t('nav.timeline')"
|
||||
:timeline="timeline"
|
||||
:timeline-name="'friends'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script src="./friends_timeline.js"></script>
|
||||
|
@ -0,0 +1,53 @@
|
||||
const Importer = {
|
||||
props: {
|
||||
submitHandler: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
submitButtonLabel: {
|
||||
type: String,
|
||||
default () {
|
||||
return this.$t('importer.submit')
|
||||
}
|
||||
},
|
||||
successMessage: {
|
||||
type: String,
|
||||
default () {
|
||||
return this.$t('importer.success')
|
||||
}
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default () {
|
||||
return this.$t('importer.error')
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
file: null,
|
||||
error: false,
|
||||
success: false,
|
||||
submitting: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
change () {
|
||||
this.file = this.$refs.input.files[0]
|
||||
},
|
||||
submit () {
|
||||
this.dismiss()
|
||||
this.submitting = true
|
||||
this.submitHandler(this.file)
|
||||
.then(() => { this.success = true })
|
||||
.catch(() => { this.error = true })
|
||||
.finally(() => { this.submitting = false })
|
||||
},
|
||||
dismiss () {
|
||||
this.success = false
|
||||
this.error = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Importer
|
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="importer">
|
||||
<form>
|
||||
<input
|
||||
ref="input"
|
||||
type="file"
|
||||
@change="change"
|
||||
>
|
||||
</form>
|
||||
<i
|
||||
v-if="submitting"
|
||||
class="icon-spin4 animate-spin importer-uploading"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-default"
|
||||
@click="submit"
|
||||
>
|
||||
{{ submitButtonLabel }}
|
||||
</button>
|
||||
<div v-if="success">
|
||||
<i
|
||||
class="icon-cross"
|
||||
@click="dismiss"
|
||||
/>
|
||||
<p>{{ successMessage }}</p>
|
||||
</div>
|
||||
<div v-else-if="error">
|
||||
<i
|
||||
class="icon-cross"
|
||||
@click="dismiss"
|
||||
/>
|
||||
<p>{{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./importer.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
.importer {
|
||||
&-uploading {
|
||||
font-size: 1.5em;
|
||||
margin: 0.25em;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,25 @@
|
||||
import Notifications from '../notifications/notifications.vue'
|
||||
|
||||
const tabModeDict = {
|
||||
mentions: ['mention'],
|
||||
'likes+repeats': ['repeat', 'like'],
|
||||
follows: ['follow']
|
||||
}
|
||||
|
||||
const Interactions = {
|
||||
data () {
|
||||
return {
|
||||
filterMode: tabModeDict['mentions']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onModeSwitch (index, dataset) {
|
||||
this.filterMode = tabModeDict[dataset.filter]
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Notifications
|
||||
}
|
||||
}
|
||||
|
||||
export default Interactions
|
@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<div class="title">
|
||||
{{ $t("nav.interactions") }}
|
||||
</div>
|
||||
</div>
|
||||
<tab-switcher
|
||||
ref="tabSwitcher"
|
||||
:on-switch="onModeSwitch"
|
||||
>
|
||||
<span
|
||||
data-tab-dummy
|
||||
data-filter="mentions"
|
||||
:label="$t('nav.mentions')"
|
||||
/>
|
||||
<span
|
||||
data-tab-dummy
|
||||
data-filter="likes+repeats"
|
||||
:label="$t('interactions.favs_repeats')"
|
||||
/>
|
||||
<span
|
||||
data-tab-dummy
|
||||
data-filter="follows"
|
||||
:label="$t('interactions.follows')"
|
||||
/>
|
||||
</tab-switcher>
|
||||
<Notifications
|
||||
ref="notifications"
|
||||
:no-heading="true"
|
||||
:minimal-mode="true"
|
||||
:filter-mode="filterMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./interactions.js"></script>
|
@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="list">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="getKey(item)"
|
||||
class="list-item"
|
||||
>
|
||||
<slot
|
||||
name="item"
|
||||
:item="item"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="items.length === 0 && !!$slots.empty"
|
||||
class="list-empty-content faint"
|
||||
>
|
||||
<slot name="empty" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
getKey: {
|
||||
type: Function,
|
||||
default: item => item.id
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.list {
|
||||
&-item:not(:last-child) {
|
||||
border-bottom: 1px solid;
|
||||
border-bottom-color: $fallback--border;
|
||||
border-bottom-color: var(--border, $fallback--border);
|
||||
}
|
||||
|
||||
&-empty-content {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<Timeline :title="$t('nav.mentions')" v-bind:timeline="timeline" v-bind:timeline-name="'mentions'"/>
|
||||
<Timeline
|
||||
:title="$t('nav.interactions')"
|
||||
:timeline="timeline"
|
||||
:timeline-name="'mentions'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script src="./mentions.js"></script>
|
||||
|
@ -0,0 +1,41 @@
|
||||
import mfaApi from '../../services/new_api/mfa.js'
|
||||
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
code: null,
|
||||
error: false
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters({
|
||||
authApp: 'authFlow/app',
|
||||
authSettings: 'authFlow/settings'
|
||||
}),
|
||||
...mapState({ instance: 'instance' })
|
||||
},
|
||||
methods: {
|
||||
...mapMutations('authFlow', ['requireTOTP', 'abortMFA']),
|
||||
...mapActions({ login: 'authFlow/login' }),
|
||||
clearError () { this.error = false },
|
||||
submit () {
|
||||
const data = {
|
||||
app: this.authApp,
|
||||
instance: this.instance.server,
|
||||
mfaToken: this.authSettings.mfa_token,
|
||||
code: this.code
|
||||
}
|
||||
|
||||
mfaApi.verifyRecoveryCode(data).then((result) => {
|
||||
if (result.error) {
|
||||
this.error = result.error
|
||||
this.code = null
|
||||
return
|
||||
}
|
||||
|
||||
this.login(result).then(() => {
|
||||
this.$router.push({ name: 'friends' })
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="login panel panel-default">
|
||||
<!-- Default panel contents -->
|
||||
|
||||
<div class="panel-heading">
|
||||
{{ $t('login.heading.recovery') }}
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<form
|
||||
class="login-form"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="form-group">
|
||||
<label for="code">{{ $t('login.recovery_code') }}</label>
|
||||
<input
|
||||
id="code"
|
||||
v-model="code"
|
||||
class="form-control"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="login-bottom">
|
||||
<div>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="requireTOTP"
|
||||
>
|
||||
{{ $t('login.enter_two_factor_code') }}
|
||||
</a>
|
||||
<br>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="abortMFA"
|
||||
>
|
||||
{{ $t('general.cancel') }}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-default"
|
||||
>
|
||||
{{ $t('general.verify') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="form-group"
|
||||
>
|
||||
<div class="alert error">
|
||||
{{ error }}
|
||||
<i
|
||||
class="button-icon icon-cancel"
|
||||
@click="clearError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script src="./recovery_form.js" ></script>
|
@ -0,0 +1,40 @@
|
||||
import mfaApi from '../../services/new_api/mfa.js'
|
||||
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
|
||||
export default {
|
||||
data: () => ({
|
||||
code: null,
|
||||
error: false
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters({
|
||||
authApp: 'authFlow/app',
|
||||
authSettings: 'authFlow/settings'
|
||||
}),
|
||||
...mapState({ instance: 'instance' })
|
||||
},
|
||||
methods: {
|
||||
...mapMutations('authFlow', ['requireRecovery', 'abortMFA']),
|
||||
...mapActions({ login: 'authFlow/login' }),
|
||||
clearError () { this.error = false },
|
||||
submit () {
|
||||
const data = {
|
||||
app: this.authApp,
|
||||
instance: this.instance.server,
|
||||
mfaToken: this.authSettings.mfa_token,
|
||||
code: this.code
|
||||
}
|
||||
|
||||
mfaApi.verifyOTPCode(data).then((result) => {
|
||||
if (result.error) {
|
||||
this.error = result.error
|
||||
this.code = null
|
||||
return
|
||||
}
|
||||
|
||||
this.login(result).then(() => {
|
||||
this.$router.push({ name: 'friends' })
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="login panel panel-default">
|
||||
<!-- Default panel contents -->
|
||||
|
||||
<div class="panel-heading">
|
||||
{{ $t('login.heading.totp') }}
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<form
|
||||
class="login-form"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="form-group">
|
||||
<label for="code">
|
||||
{{ $t('login.authentication_code') }}
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
v-model="code"
|
||||
class="form-control"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="login-bottom">
|
||||
<div>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="requireRecovery"
|
||||
>
|
||||
{{ $t('login.enter_recovery_code') }}
|
||||
</a>
|
||||
<br>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="abortMFA"
|
||||
>
|
||||
{{ $t('general.cancel') }}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-default"
|
||||
>
|
||||
{{ $t('general.verify') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="form-group"
|
||||
>
|
||||
<div class="alert error">
|
||||
{{ error }}
|
||||
<i
|
||||
class="button-icon icon-cancel"
|
||||
@click="clearError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script src="./totp_form.js"></script>
|
@ -0,0 +1,101 @@
|
||||
import DialogModal from '../dialog_modal/dialog_modal.vue'
|
||||
|
||||
const FORCE_NSFW = 'mrf_tag:media-force-nsfw'
|
||||
const STRIP_MEDIA = 'mrf_tag:media-strip'
|
||||
const FORCE_UNLISTED = 'mrf_tag:force-unlisted'
|
||||
const DISABLE_REMOTE_SUBSCRIPTION = 'mrf_tag:disable-remote-subscription'
|
||||
const DISABLE_ANY_SUBSCRIPTION = 'mrf_tag:disable-any-subscription'
|
||||
const SANDBOX = 'mrf_tag:sandbox'
|
||||
const QUARANTINE = 'mrf_tag:quarantine'
|
||||
|
||||
const ModerationTools = {
|
||||
props: [
|
||||
'user'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
showDropDown: false,
|
||||
tags: {
|
||||
FORCE_NSFW,
|
||||
STRIP_MEDIA,
|
||||
FORCE_UNLISTED,
|
||||
DISABLE_REMOTE_SUBSCRIPTION,
|
||||
DISABLE_ANY_SUBSCRIPTION,
|
||||
SANDBOX,
|
||||
QUARANTINE
|
||||
},
|
||||
showDeleteUserDialog: false
|
||||
}
|
||||
},
|
||||
components: {
|
||||
DialogModal
|
||||
},
|
||||
computed: {
|
||||
tagsSet () {
|
||||
return new Set(this.user.tags)
|
||||
},
|
||||
hasTagPolicy () {
|
||||
return this.$store.state.instance.tagPolicyAvailable
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hasTag (tagName) {
|
||||
return this.tagsSet.has(tagName)
|
||||
},
|
||||
toggleTag (tag) {
|
||||
const store = this.$store
|
||||
if (this.tagsSet.has(tag)) {
|
||||
store.state.api.backendInteractor.untagUser(this.user, tag).then(response => {
|
||||
if (!response.ok) { return }
|
||||
store.commit('untagUser', { user: this.user, tag })
|
||||
})
|
||||
} else {
|
||||
store.state.api.backendInteractor.tagUser(this.user, tag).then(response => {
|
||||
if (!response.ok) { return }
|
||||
store.commit('tagUser', { user: this.user, tag })
|
||||
})
|
||||
}
|
||||
},
|
||||
toggleRight (right) {
|
||||
const store = this.$store
|
||||
if (this.user.rights[right]) {
|
||||
store.state.api.backendInteractor.deleteRight(this.user, right).then(response => {
|
||||
if (!response.ok) { return }
|
||||
store.commit('updateRight', { user: this.user, right: right, value: false })
|
||||
})
|
||||
} else {
|
||||
store.state.api.backendInteractor.addRight(this.user, right).then(response => {
|
||||
if (!response.ok) { return }
|
||||
store.commit('updateRight', { user: this.user, right: right, value: true })
|
||||
})
|
||||
}
|
||||
},
|
||||
toggleActivationStatus () {
|
||||
const store = this.$store
|
||||
const status = !!this.user.deactivated
|
||||
store.state.api.backendInteractor.setActivationStatus(this.user, status).then(response => {
|
||||
if (!response.ok) { return }
|
||||
store.commit('updateActivationStatus', { user: this.user, status: status })
|
||||
})
|
||||
},
|
||||
deleteUserDialog (show) {
|
||||
this.showDeleteUserDialog = show
|
||||
},
|
||||
deleteUser () {
|
||||
const store = this.$store
|
||||
const user = this.user
|
||||
const { id, name } = user
|
||||
store.state.api.backendInteractor.deleteUser(user)
|
||||
.then(e => {
|
||||
this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id)
|
||||
const isProfile = this.$route.name === 'external-user-profile' || this.$route.name === 'user-profile'
|
||||
const isTargetUser = this.$route.params.name === name || this.$route.params.id === id
|
||||
if (isProfile && isTargetUser) {
|
||||
window.history.back()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ModerationTools
|
@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-popover
|
||||
trigger="click"
|
||||
class="moderation-tools-popover"
|
||||
:container="false"
|
||||
placement="bottom-end"
|
||||
:offset="5"
|
||||
@show="showDropDown = true"
|
||||
@hide="showDropDown = false"
|
||||
>
|
||||
<div slot="popover">
|
||||
<div class="dropdown-menu">
|
||||
<span v-if="user.is_local">
|
||||
<button
|
||||
class="dropdown-item"
|
||||
@click="toggleRight("admin")"
|
||||
>
|
||||
{{ $t(!!user.rights.admin ? 'user_card.admin_menu.revoke_admin' : 'user_card.admin_menu.grant_admin') }}
|
||||
</button>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
@click="toggleRight("moderator")"
|
||||
>
|
||||
{{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }}
|
||||
</button>
|
||||
<div
|
||||
role="separator"
|
||||
class="dropdown-divider"
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
@click="toggleActivationStatus()"
|
||||
>
|
||||
{{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }}
|
||||
</button>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
@click="deleteUserDialog(true)"
|
||||
>
|
||||
{{ $t('user_card.admin_menu.delete_account') }}
|
||||
</button>
|
||||
<div
|
||||
v-if="hasTagPolicy"
|
||||
role="separator"
|
||||
class="dropdown-divider"
|
||||
/>
|
||||
<span v-if="hasTagPolicy">
|
||||
<button
|
||||
class="dropdown-item"
|
||||
@click="toggleTag(tags.FORCE_NSFW)"
|
||||
>
|
||||
{{ $t('user_card.admin_menu.force_nsfw') }}
|
||||
<span
|
||||
class="menu-checkbox"
|
||||
:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
@click="toggleTag(tags.STRIP_MEDIA)"
|
||||
>
|
||||
{{ $t('user_card.admin_menu.strip_media') }}
|
||||
<span
|
||||
class="menu-checkbox"
|
||||
:class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
@click="toggleTag(tags.FORCE_UNLISTED)"
|
||||
>
|
||||
{{ $t('user_card.admin_menu.force_unlisted') }}
|
||||
<span
|
||||
class="menu-checkbox"
|
||||
:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
@click="toggleTag(tags.SANDBOX)"
|
||||
>
|
||||
{{ $t('user_card.admin_menu.sandbox') }}
|
||||
<span
|
||||
class="menu-checkbox"
|
||||
:class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="user.is_local"
|
||||
class="dropdown-item"
|
||||
@click="toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)"
|
||||
>
|
||||
{{ $t('user_card.admin_menu.disable_remote_subscription') }}
|
||||
<span
|
||||
class="menu-checkbox"
|
||||
:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="user.is_local"
|
||||
class="dropdown-item"
|
||||
@click="toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)"
|
||||
>
|
||||
{{ $t('user_card.admin_menu.disable_any_subscription') }}
|
||||
<span
|
||||
class="menu-checkbox"
|
||||
:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="user.is_local"
|
||||
class="dropdown-item"
|
||||
@click="toggleTag(tags.QUARANTINE)"
|
||||
>
|
||||
{{ $t('user_card.admin_menu.quarantine') }}
|
||||
<span
|
||||
class="menu-checkbox"
|
||||
:class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-default btn-block"
|
||||
:class="{ pressed: showDropDown }"
|
||||
>
|
||||
{{ $t('user_card.admin_menu.moderation') }}
|
||||
</button>
|
||||
</v-popover>
|
||||
<portal to="modal">
|
||||
<DialogModal
|
||||
v-if="showDeleteUserDialog"
|
||||
:on-cancel="deleteUserDialog.bind(this, false)"
|
||||
>
|
||||
<template slot="header">
|
||||
{{ $t('user_card.admin_menu.delete_user') }}
|
||||
</template>
|
||||
<p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p>
|
||||
<template slot="footer">
|
||||
<button
|
||||
class="btn btn-default"
|
||||
@click="deleteUserDialog(false)"
|
||||
>
|
||||
{{ $t('general.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-default danger"
|
||||
@click="deleteUser()"
|
||||
>
|
||||
{{ $t('user_card.admin_menu.delete_user') }}
|
||||
</button>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</portal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./moderation_tools.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
@import '../popper/popper.scss';
|
||||
|
||||
.menu-checkbox {
|
||||
float: right;
|
||||
min-width: 22px;
|
||||
max-width: 22px;
|
||||
min-height: 22px;
|
||||
max-height: 22px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
border-radius: 0px;
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--input, $fallback--fg);
|
||||
box-shadow: 0px 0px 2px black inset;
|
||||
box-shadow: var(--inputShadow);
|
||||
|
||||
&.menu-checkbox-checked::after {
|
||||
content: '✔';
|
||||
}
|
||||
}
|
||||
|
||||
.moderation-tools-popover {
|
||||
height: 100%;
|
||||
.trigger {
|
||||
display: flex !important;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,112 @@
|
||||
import Timeago from '../timeago/timeago.vue'
|
||||
import { forEach, map } from 'lodash'
|
||||
|
||||
export default {
|
||||
name: 'Poll',
|
||||
props: ['basePoll'],
|
||||
components: { Timeago },
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
choices: []
|
||||
}
|
||||
},
|
||||
created () {
|
||||
if (!this.$store.state.polls.pollsObject[this.pollId]) {
|
||||
this.$store.dispatch('mergeOrAddPoll', this.basePoll)
|
||||
}
|
||||
this.$store.dispatch('trackPoll', this.pollId)
|
||||
},
|
||||
destroyed () {
|
||||
this.$store.dispatch('untrackPoll', this.pollId)
|
||||
},
|
||||
computed: {
|
||||
pollId () {
|
||||
return this.basePoll.id
|
||||
},
|
||||
poll () {
|
||||
const storePoll = this.$store.state.polls.pollsObject[this.pollId]
|
||||
return storePoll || {}
|
||||
},
|
||||
options () {
|
||||
return (this.poll && this.poll.options) || []
|
||||
},
|
||||
expiresAt () {
|
||||
return (this.poll && this.poll.expires_at) || 0
|
||||
},
|
||||
expired () {
|
||||
return (this.poll && this.poll.expired) || false
|
||||
},
|
||||
loggedIn () {
|
||||
return this.$store.state.users.currentUser
|
||||
},
|
||||
showResults () {
|
||||
return this.poll.voted || this.expired || !this.loggedIn
|
||||
},
|
||||
totalVotesCount () {
|
||||
return this.poll.votes_count
|
||||
},
|
||||
containerClass () {
|
||||
return {
|
||||
loading: this.loading
|
||||
}
|
||||
},
|
||||
choiceIndices () {
|
||||
// Convert array of booleans into an array of indices of the
|
||||
// items that were 'true', so [true, false, false, true] becomes
|
||||
// [0, 3].
|
||||
return this.choices
|
||||
.map((entry, index) => entry && index)
|
||||
.filter(value => typeof value === 'number')
|
||||
},
|
||||
isDisabled () {
|
||||
const noChoice = this.choiceIndices.length === 0
|
||||
return this.loading || noChoice
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
percentageForOption (count) {
|
||||
return this.totalVotesCount === 0 ? 0 : Math.round(count / this.totalVotesCount * 100)
|
||||
},
|
||||
resultTitle (option) {
|
||||
return `${option.votes_count}/${this.totalVotesCount} ${this.$t('polls.votes')}`
|
||||
},
|
||||
fetchPoll () {
|
||||
this.$store.dispatch('refreshPoll', { id: this.statusId, pollId: this.poll.id })
|
||||
},
|
||||
activateOption (index) {
|
||||
// forgive me father: doing checking the radio/checkboxes
|
||||
// in code because of customized input elements need either
|
||||
// a) an extra element for the actual graphic, or b) use a
|
||||
// pseudo element for the label. We use b) which mandates
|
||||
// using "for" and "id" matching which isn't nice when the
|
||||
// same poll appears multiple times on the site (notifs and
|
||||
// timeline for example). With code we can make sure it just
|
||||
// works without altering the pseudo element implementation.
|
||||
const allElements = this.$el.querySelectorAll('input')
|
||||
const clickedElement = this.$el.querySelector(`input[value="${index}"]`)
|
||||
if (this.poll.multiple) {
|
||||
// Checkboxes, toggle only the clicked one
|
||||
clickedElement.checked = !clickedElement.checked
|
||||
} else {
|
||||
// Radio button, uncheck everything and check the clicked one
|
||||
forEach(allElements, element => { element.checked = false })
|
||||
clickedElement.checked = true
|
||||
}
|
||||
this.choices = map(allElements, e => e.checked)
|
||||
},
|
||||
optionId (index) {
|
||||
return `poll${this.poll.id}-${index}`
|
||||
},
|
||||
vote () {
|
||||
if (this.choiceIndices.length === 0) return
|
||||
this.loading = true
|
||||
this.$store.dispatch(
|
||||
'votePoll',
|
||||
{ id: this.statusId, pollId: this.poll.id, choices: this.choiceIndices }
|
||||
).then(poll => {
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div
|
||||
class="poll"
|
||||
:class="containerClass"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in options"
|
||||
:key="index"
|
||||
class="poll-option"
|
||||
>
|
||||
<div
|
||||
v-if="showResults"
|
||||
:title="resultTitle(option)"
|
||||
class="option-result"
|
||||
>
|
||||
<div class="option-result-label">
|
||||
<span class="result-percentage">
|
||||
{{ percentageForOption(option.votes_count) }}%
|
||||
</span>
|
||||
<span>{{ option.title }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="result-fill"
|
||||
:style="{ 'width': `${percentageForOption(option.votes_count)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@click="activateOption(index)"
|
||||
>
|
||||
<input
|
||||
v-if="poll.multiple"
|
||||
type="checkbox"
|
||||
:disabled="loading"
|
||||
:value="index"
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
type="radio"
|
||||
:disabled="loading"
|
||||
:value="index"
|
||||
>
|
||||
<label class="option-vote">
|
||||
<div>{{ option.title }}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer faint">
|
||||
<button
|
||||
v-if="!showResults"
|
||||
class="btn btn-default poll-vote-button"
|
||||
type="button"
|
||||
:disabled="isDisabled"
|
||||
@click="vote"
|
||||
>
|
||||
{{ $t('polls.vote') }}
|
||||
</button>
|
||||
<div class="total">
|
||||
{{ totalVotesCount }} {{ $t("polls.votes") }} ·
|
||||
</div>
|
||||
<i18n :path="expired ? 'polls.expired' : 'polls.expires_in'">
|
||||
<Timeago
|
||||
:time="expiresAt"
|
||||
:auto-update="60"
|
||||
:now-threshold="0"
|
||||
/>
|
||||
</i18n>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./poll.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.poll {
|
||||
.votes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
.poll-option {
|
||||
margin: 0.75em 0.5em;
|
||||
}
|
||||
.option-result {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
color: $fallback--lightText;
|
||||
color: var(--lightText, $fallback--lightText);
|
||||
}
|
||||
.option-result-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.1em 0.25em;
|
||||
z-index: 1;
|
||||
}
|
||||
.result-percentage {
|
||||
width: 3.5em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.result-fill {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background-color: $fallback--lightBg;
|
||||
background-color: var(--linkBg, $fallback--lightBg);
|
||||
border-radius: $fallback--panelRadius;
|
||||
border-radius: var(--panelRadius, $fallback--panelRadius);
|
||||
top: 0;
|
||||
left: 0;
|
||||
transition: width 0.5s;
|
||||
}
|
||||
.option-vote {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
input {
|
||||
width: 3.5em;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
&.loading * {
|
||||
cursor: progress;
|
||||
}
|
||||
.poll-vote-button {
|
||||
padding: 0 0.5em;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,121 @@
|
||||
import * as DateUtils from 'src/services/date_utils/date_utils.js'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
export default {
|
||||
name: 'PollForm',
|
||||
props: ['visible'],
|
||||
data: () => ({
|
||||
pollType: 'single',
|
||||
options: ['', ''],
|
||||
expiryAmount: 10,
|
||||
expiryUnit: 'minutes'
|
||||
}),
|
||||
computed: {
|
||||
pollLimits () {
|
||||
return this.$store.state.instance.pollLimits
|
||||
},
|
||||
maxOptions () {
|
||||
return this.pollLimits.max_options
|
||||
},
|
||||
maxLength () {
|
||||
return this.pollLimits.max_option_chars
|
||||
},
|
||||
expiryUnits () {
|
||||
const allUnits = ['minutes', 'hours', 'days']
|
||||
const expiry = this.convertExpiryFromUnit
|
||||
return allUnits.filter(
|
||||
unit => this.pollLimits.max_expiration >= expiry(unit, 1)
|
||||
)
|
||||
},
|
||||
minExpirationInCurrentUnit () {
|
||||
return Math.ceil(
|
||||
this.convertExpiryToUnit(
|
||||
this.expiryUnit,
|
||||
this.pollLimits.min_expiration
|
||||
)
|
||||
)
|
||||
},
|
||||
maxExpirationInCurrentUnit () {
|
||||
return Math.floor(
|
||||
this.convertExpiryToUnit(
|
||||
this.expiryUnit,
|
||||
this.pollLimits.max_expiration
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clear () {
|
||||
this.pollType = 'single'
|
||||
this.options = ['', '']
|
||||
this.expiryAmount = 10
|
||||
this.expiryUnit = 'minutes'
|
||||
},
|
||||
nextOption (index) {
|
||||
const element = this.$el.querySelector(`#poll-${index + 1}`)
|
||||
if (element) {
|
||||
element.focus()
|
||||
} else {
|
||||
// Try adding an option and try focusing on it
|
||||
const addedOption = this.addOption()
|
||||
if (addedOption) {
|
||||
this.$nextTick(function () {
|
||||
this.nextOption(index)
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
addOption () {
|
||||
if (this.options.length < this.maxOptions) {
|
||||
this.options.push('')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
deleteOption (index, event) {
|
||||
if (this.options.length > 2) {
|
||||
this.options.splice(index, 1)
|
||||
}
|
||||
},
|
||||
convertExpiryToUnit (unit, amount) {
|
||||
// Note: we want seconds and not milliseconds
|
||||
switch (unit) {
|
||||
case 'minutes': return (1000 * amount) / DateUtils.MINUTE
|
||||
case 'hours': return (1000 * amount) / DateUtils.HOUR
|
||||
case 'days': return (1000 * amount) / DateUtils.DAY
|
||||
}
|
||||
},
|
||||
convertExpiryFromUnit (unit, amount) {
|
||||
// Note: we want seconds and not milliseconds
|
||||
switch (unit) {
|
||||
case 'minutes': return 0.001 * amount * DateUtils.MINUTE
|
||||
case 'hours': return 0.001 * amount * DateUtils.HOUR
|
||||
case 'days': return 0.001 * amount * DateUtils.DAY
|
||||
}
|
||||
},
|
||||
expiryAmountChange () {
|
||||
this.expiryAmount =
|
||||
Math.max(this.minExpirationInCurrentUnit, this.expiryAmount)
|
||||
this.expiryAmount =
|
||||
Math.min(this.maxExpirationInCurrentUnit, this.expiryAmount)
|
||||
this.updatePollToParent()
|
||||
},
|
||||
updatePollToParent () {
|
||||
const expiresIn = this.convertExpiryFromUnit(
|
||||
this.expiryUnit,
|
||||
this.expiryAmount
|
||||
)
|
||||
|
||||
const options = uniq(this.options.filter(option => option !== ''))
|
||||
if (options.length < 2) {
|
||||
this.$emit('update-poll', { error: this.$t('polls.not_enough_options') })
|
||||
return
|
||||
}
|
||||
this.$emit('update-poll', {
|
||||
options,
|
||||
multiple: this.pollType === 'multiple',
|
||||
expiresIn
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="poll-form"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in options"
|
||||
:key="index"
|
||||
class="poll-option"
|
||||
>
|
||||
<div class="input-container">
|
||||
<input
|
||||
:id="`poll-${index}`"
|
||||
v-model="options[index]"
|
||||
class="poll-option-input"
|
||||
type="text"
|
||||
:placeholder="$t('polls.option')"
|
||||
:maxlength="maxLength"
|
||||
@change="updatePollToParent"
|
||||
@keydown.enter.stop.prevent="nextOption(index)"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="options.length > 2"
|
||||
class="icon-container"
|
||||
>
|
||||
<i
|
||||
class="icon-cancel"
|
||||
@click="deleteOption(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
v-if="options.length < maxOptions"
|
||||
class="add-option faint"
|
||||
@click="addOption"
|
||||
>
|
||||
<i class="icon-plus" />
|
||||
{{ $t("polls.add_option") }}
|
||||
</a>
|
||||
<div class="poll-type-expiry">
|
||||
<div
|
||||
class="poll-type"
|
||||
:title="$t('polls.type')"
|
||||
>
|
||||
<label
|
||||
for="poll-type-selector"
|
||||
class="select"
|
||||
>
|
||||
<select
|
||||
v-model="pollType"
|
||||
class="select"
|
||||
@change="updatePollToParent"
|
||||
>
|
||||
<option value="single">{{ $t('polls.single_choice') }}</option>
|
||||
<option value="multiple">{{ $t('polls.multiple_choices') }}</option>
|
||||
</select>
|
||||
<i class="icon-down-open" />
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="poll-expiry"
|
||||
:title="$t('polls.expiry')"
|
||||
>
|
||||
<input
|
||||
v-model="expiryAmount"
|
||||
type="number"
|
||||
class="expiry-amount hide-number-spinner"
|
||||
:min="minExpirationInCurrentUnit"
|
||||
:max="maxExpirationInCurrentUnit"
|
||||
@change="expiryAmountChange"
|
||||
>
|
||||
<label class="expiry-unit select">
|
||||
<select
|
||||
v-model="expiryUnit"
|
||||
@change="expiryAmountChange"
|
||||
>
|
||||
<option
|
||||
v-for="unit in expiryUnits"
|
||||
:key="unit"
|
||||
:value="unit"
|
||||
>
|
||||
{{ $t(`time.${unit}_short`, ['']) }}
|
||||
</option>
|
||||
</select>
|
||||
<i class="icon-down-open" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./poll_form.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.poll-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.5em 0.5em;
|
||||
|
||||
.add-option {
|
||||
align-self: flex-start;
|
||||
padding-top: 0.25em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.poll-option {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
width: 100%;
|
||||
input {
|
||||
// Hack: dodge the floating X icon
|
||||
padding-right: 2.5em;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
// Hack: Move the icon over the input box
|
||||
width: 2em;
|
||||
margin-left: -2em;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.poll-type-expiry {
|
||||
margin-top: 0.5em;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.poll-type {
|
||||
margin-right: 0.75em;
|
||||
flex: 1 1 60%;
|
||||
.select {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.poll-expiry {
|
||||
display: flex;
|
||||
|
||||
.expiry-amount {
|
||||
width: 3em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.expiry-unit {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,148 @@
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.tooltip.popover {
|
||||
z-index: 8;
|
||||
|
||||
.popover-inner {
|
||||
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
|
||||
box-shadow: var(--panelShadow);
|
||||
border-radius: $fallback--btnRadius;
|
||||
border-radius: var(--btnRadius, $fallback--btnRadius);
|
||||
background-color: $fallback--bg;
|
||||
background-color: var(--bg, $fallback--bg);
|
||||
}
|
||||
|
||||
.popover-arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
position: absolute;
|
||||
margin: 5px;
|
||||
border-color: $fallback--bg;
|
||||
border-color: var(--bg, $fallback--bg);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&[x-placement^="top"] {
|
||||
margin-bottom: 5px;
|
||||
|
||||
.popover-arrow {
|
||||
border-width: 5px 5px 0 5px;
|
||||
border-left-color: transparent !important;
|
||||
border-right-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
bottom: -5px;
|
||||
left: calc(50% - 5px);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&[x-placement^="bottom"] {
|
||||
margin-top: 5px;
|
||||
|
||||
.popover-arrow {
|
||||
border-width: 0 5px 5px 5px;
|
||||
border-left-color: transparent !important;
|
||||
border-right-color: transparent !important;
|
||||
border-top-color: transparent !important;
|
||||
top: -5px;
|
||||
left: calc(50% - 5px);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&[x-placement^="right"] {
|
||||
margin-left: 5px;
|
||||
|
||||
.popover-arrow {
|
||||
border-width: 5px 5px 5px 0;
|
||||
border-left-color: transparent !important;
|
||||
border-top-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
left: -5px;
|
||||
top: calc(50% - 5px);
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&[x-placement^="left"] {
|
||||
margin-right: 5px;
|
||||
|
||||
.popover-arrow {
|
||||
border-width: 5px 0 5px 5px;
|
||||
border-top-color: transparent !important;
|
||||
border-right-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
right: -5px;
|
||||
top: calc(50% - 5px);
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-hidden='true'] {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity .15s, visibility .15s;
|
||||
}
|
||||
|
||||
&[aria-hidden='false'] {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
display: block;
|
||||
padding: .5rem 0;
|
||||
font-size: 1rem;
|
||||
text-align: left;
|
||||
list-style: none;
|
||||
max-width: 100vw;
|
||||
z-index: 10;
|
||||
|
||||
.dropdown-divider {
|
||||
height: 0;
|
||||
margin: .5rem 0;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid $fallback--border;
|
||||
border-top: 1px solid var(--border, $fallback--border);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
line-height: 21px;
|
||||
margin-right: 5px;
|
||||
overflow: auto;
|
||||
display: block;
|
||||
padding: .25rem 1.0rem .25rem 1.5rem;
|
||||
clear: both;
|
||||
font-weight: 400;
|
||||
text-align: inherit;
|
||||
white-space: normal;
|
||||
border: none;
|
||||
border-radius: 0px;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&-icon {
|
||||
padding-left: 0.5rem;
|
||||
|
||||
i {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
// TODO: improve the look on breeze themes
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--btn, $fallback--fg);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<button
|
||||
:disabled="progress || disabled"
|
||||
@click="onClick"
|
||||
>
|
||||
<template v-if="progress && $slots.progress">
|
||||
<slot name="progress" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot />
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean
|
||||
},
|
||||
click: { // click event handler. Must return a promise
|
||||
type: Function,
|
||||
default: () => Promise.resolve()
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
progress: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onClick () {
|
||||
this.progress = true
|
||||
this.click().then(() => { this.progress = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<Timeline :title="$t('nav.twkn')" v-bind:timeline="timeline" v-bind:timeline-name="'publicAndExternal'"/>
|
||||
<Timeline
|
||||
:title="$t('nav.twkn')"
|
||||
:timeline="timeline"
|
||||
:timeline-name="'publicAndExternal'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script src="./public_and_external_timeline.js"></script>
|
||||
|
@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<Timeline :title="$t('nav.public_tl')" v-bind:timeline="timeline" v-bind:timeline-name="'public'"/>
|
||||
<Timeline
|
||||
:title="$t('nav.public_tl')"
|
||||
:timeline="timeline"
|
||||
:timeline-name="'public'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script src="./public_timeline.js"></script>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue