diff --git a/front/src/components/common/Tooltip.vue b/front/src/components/common/Tooltip.vue new file mode 100644 index 0000000000000000000000000000000000000000..d9ba4c13cd9fbe4312c758cc6566d6734878bdd5 --- /dev/null +++ b/front/src/components/common/Tooltip.vue @@ -0,0 +1,12 @@ +<template> + <span class="tooltip" :data-tooltip="content"><i class="question circle icon"></i></span> +</template> + +<script> + +export default { + props: { + content: {type: String, required: true}, + } +} +</script> diff --git a/front/src/components/globals.js b/front/src/components/globals.js index d5a1fb4a447eb6bf819b6fbf6e2a4c370c60ae0f..99e57095c0735a96c55d99e8fd00827203eaa929 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -40,5 +40,9 @@ import AjaxButton from '@/components/common/AjaxButton' Vue.component('ajax-button', AjaxButton) +import Tooltip from '@/components/common/Tooltip' + +Vue.component('tooltip', Tooltip) + export default {} diff --git a/front/src/components/manage/moderation/InstancePolicyCard.vue b/front/src/components/manage/moderation/InstancePolicyCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..c7d11585611fa02aab39f2d88ff36aaa31249523 --- /dev/null +++ b/front/src/components/manage/moderation/InstancePolicyCard.vue @@ -0,0 +1,72 @@ +<template> + <div> + <slot></slot> + <p> + <i class="clock outline icon"></i><human-date :date="object.creation_date" /> + <i class="user icon"></i>{{ object.actor }} + <template v-if="object.is_active"> + <i class="play icon"></i> + <translate>Enabled</translate> + </template> + <template v-if="!object.is_active"> + <i class="pause icon"></i> + <translate>Paused</translate> + </template> + </p> + <div> + <p><strong><translate>Rule</translate></strong></p> + <p v-if="object.block_all"> + <i class="ban icon"></i> + <translate>Block everything</translate> + </p> + <div v-else class="ui list"> + <div class="ui item" v-if="object.silence_activity"> + <i class="feed icon"></i> + <div class="content"><translate>Silence activity</translate></div> + </div> + <div class="ui item" v-if="object.silence_notifications"> + <i class="bell icon"></i> + <div class="content"><translate>Silence notifications</translate></div> + </div> + <div class="ui item" v-if="object.reject_media"> + <i class="file icon"></i> + <div class="content"><translate>Reject media</translate></div> + </div> + + </div> + </div> + <div v-if="markdown && object.summary"> + <div class="ui hidden divider"></div> + <p><strong><translate>Reason</translate></strong></p> + <div v-html="markdown.makeHtml(object.summary)"></div> + </div> + <div class="ui hidden divider"></div> + <button @click="$emit('update')" class="ui right floated labeled icon button"> + <i class="edit icon"></i> + <translate>Update</translate> + </button> + </div> +</template> + +<script> + +export default { + props: { + object: {type: Object, default: null}, + }, + data () { + return { + markdown: null + } + }, + created () { + let self = this + import('showdown').then(module => { + self.markdown = new module.default.Converter({simplifiedAutoLink: true, openLinksInNewWindow: true}) + }) + } +} +</script> + +<style scoped> +</style> diff --git a/front/src/components/manage/moderation/InstancePolicyForm.vue b/front/src/components/manage/moderation/InstancePolicyForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..d3c8d6d6e7316bca39d0ef70d702e5bbd60931c2 --- /dev/null +++ b/front/src/components/manage/moderation/InstancePolicyForm.vue @@ -0,0 +1,212 @@ +<template> + <form class="ui form" @submit.prevent="createOrUpdate"> + <h3 class="ui header"> + <translate v-if="object" key="1">Update moderation rule</translate> + <translate v-else key="2">Add a new moderation rule</translate> + </h3> + <div v-if="errors && errors.length > 0" class="ui negative message"> + <div class="header"><translate>Error while creating rule</translate></div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <div class="field"> + <label for="policy-summary"> + <translate>Reason</translate> + <tooltip :content="labels.summaryHelp" /> + </label> + <textarea name="policy-summary" id="policy-summary" rows="5" v-model="current.summary"></textarea> + </div> + <div class="field"> + <div class="ui toggle checkbox"> + <input id="policy-is-active" v-model="current.blockAll" type="checkbox"> + <label for="policy-is-active"> + <translate>Block everything</translate> + <tooltip :content="labels.blockAllHelp" /> + </label> + </div> + </div> + <div class="ui horizontal divider"> + <translate>Or customize your rule</translate> + </div> + <div v-for="config in fieldConfig" :class="['field']"> + <div class="ui toggle checkbox"> + <input :id="'policy-' + config.id" v-model="current[config.id]" type="checkbox"> + <label :for="'policy-' + config.id"> + <i :class="[config.icon, 'icon']"></i> + {{ labels[config.id].label }} + <tooltip :content="labels[config.id].help" /> + </label> + </div> + </div> + <div class="field" v-if="object"> + <div class="ui toggle checkbox"> + <input id="policy-is-active" v-model="current.isActive" type="checkbox"> + <label for="policy-is-active"> + <translate v-if="current.isActive" key="1">Enabled</translate> + <translate v-else key="2">Disabled</translate> + <tooltip :content="labels.isActiveHelp" /> + </label> + </div> + </div> + <div class="ui hidden divider"></div> + <button @click="$emit('cancel')" class="ui basic left floated button"> + <translate>Cancel</translate> + </button> + <button :class="['ui', 'right', 'floated', 'green', {'disabled loading': isLoading}, 'button']" :disabled="isLoading"> + <translate v-if="object" key="1">Update</translate> + <translate v-else key="2">Create</translate> + </button> + <dangerous-button v-if="object" class="right floated basic button" color='red' @confirm="remove"> + <translate>Delete</translate> + <p slot="modal-header"> + <translate>Delete this moderation rule?</translate> + </p> + <p slot="modal-content"> + <translate>This action is irreversible.</translate> + </p> + <p slot="modal-confirm"> + <translate>Delete moderation rule</translate> + </p> + </dangerous-button> + </form> +</template> + +<script> +import axios from 'axios' +import _ from 'lodash' + +export default { + props: { + type: {type: String, required: true}, + object: {type: Object, default: null}, + target: {type: String, required: true}, + }, + data () { + let current = this.object || {} + return { + isLoading: false, + errors: [], + current: { + summary: _.get(current, 'summary', ''), + isActive: _.get(current, 'is_active', true), + blockAll: _.get(current, 'block_all', true), + silenceActivity: _.get(current, 'silence_activity', false), + silenceNotifications: _.get(current, 'silence_notifications', false), + rejectMedia: _.get(current, 'reject_media', false), + }, + fieldConfig: [ + {id: "silenceActivity", icon: "feed"}, + {id: "silenceNotifications", icon: "bell"}, + {id: "rejectMedia", icon: "file"}, + ] + } + }, + computed: { + labels () { + return { + summaryHelp: this.$gettext("Explain why you're applying this policy. Depending on your instance configuration, this will help you remember why you acted on this account or domain, and may be displayed publicly to help users understand what moderation rules are in place."), + isActiveHelp: this.$gettext("Use this setting to temporarily enable/disable the policy without completely removing it."), + blockAllHelp: this.$gettext("Block everything from this account or domain. This will prevent any interaction with the entity."), + silenceActivity: { + help: this.$gettext("Hide account or domain content, except from followers."), + label: this.$gettext("Silence activity"), + }, + silenceNotifications: { + help: this.$gettext("Prevent account or domain from triggering notifications, except from followers."), + label: this.$gettext("Silence notifications"), + }, + rejectMedia: { + help: this.$gettext("Do not download any media file (audio, album cover, account avatar…) from this account or domain."), + label: this.$gettext("Reject media"), + } + } + } + }, + methods: { + createOrUpdate () { + let self = this + this.isLoading = true + this.errors = [] + let url, method + let data = { + summary: this.current.summary, + is_active: this.current.isActive, + block_all: this.current.blockAll, + silence_activity: this.current.silenceActivity, + silence_notifications: this.current.silenceNotifications, + reject_media: this.current.rejectMedia, + target: { + type: this.type, + id: this.target, + } + } + if (this.object) { + url = `manage/moderation/instance-policies/${this.object.id}/` + method = 'patch' + } else { + url = `manage/moderation/instance-policies/` + method = 'post' + } + axios[method](url, data).then((response) => { + this.isLoading = false + self.$emit('save', response.data) + }, (error) => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + remove () { + let self = this + this.isLoading = true + this.errors = [] + + let url = `manage/moderation/instance-policies/${this.object.id}/` + axios.delete(url).then((response) => { + this.isLoading = false + self.$emit('delete') + }, (error) => { + self.isLoading = false + self.errors = error.backendErrors + }) + } + }, + watch: { + 'current.silenceActivity': function (v) { + if (v) { + this.current.blockAll = false + } + }, + 'current.silenceNotifications': function (v) { + if (v) { + this.current.blockAll = false + } + }, + 'current.rejectMedia': function (v) { + if (v) { + this.current.blockAll = false + } + }, + 'current.blockAll': function (v) { + if (v) { + let self = this + this.fieldConfig.forEach((f) => { + self.current[f.id] = false + }) + } + } + } +} +</script> + +<style scoped> +.ui.placeholder.segment .field, +.ui.placeholder.segment textarea, +.ui.placeholder.segment > .ui.input, +.ui.placeholder.segment .button { + max-width: 100%; +} +.segment .right.floated.button { + margin-left: 1em; +} +</style> diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index 1ce8144c66c6e7473937a9dffc21297e5c71257a..0c165c76f1ccfd99a090c02ae3cb0bfd08ad3e94 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -255,7 +255,11 @@ button.reset { [data-tooltip]::after { white-space: normal; - width: 300px; - max-width: 300px; + width: 500px; + max-width: 500px; z-index: 999; } + +label .tooltip { + margin-left: 1em; +} diff --git a/front/src/views/admin/moderation/DomainsDetail.vue b/front/src/views/admin/moderation/DomainsDetail.vue index 1adb1c3055b063e21669ec97d276a047b277b9ae..f5f9643c8a0eddcf65098d6076ddffc9fe99aa13 100644 --- a/front/src/views/admin/moderation/DomainsDetail.vue +++ b/front/src/views/admin/moderation/DomainsDetail.vue @@ -5,19 +5,61 @@ </div> <template v-if="object"> <section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.name"> - <div class="segment-content"> - <h2 class="ui header"> - <i class="circular inverted cloud icon"></i> - <div class="content"> - {{ object.name }} - <div class="sub header"> - <a :href="externalUrl" target="_blank" rel="noopener noreferrer" class="logo-wrapper"> - <translate>Open website</translate> - <i class="external icon"></i> - </a> - </div> + <div class="ui stackable two column grid"> + <div class="ui column"> + <div class="segment-content"> + <h2 class="ui header"> + <i class="circular inverted cloud icon"></i> + <div class="content"> + {{ object.name }} + <div class="sub header"> + <a :href="externalUrl" target="_blank" rel="noopener noreferrer" class="logo-wrapper"> + <translate>Open website</translate> + <i class="external icon"></i> + </a> + </div> + </div> + </h2> + </div> + </div> + <div class="ui column"> + <div class="ui compact clearing placeholder segment"> + <template v-if="isLoadingPolicy"> + <div class="paragraph"> + <div class="line"></div> + <div class="line"></div> + <div class="line"></div> + <div class="line"></div> + <div class="line"></div> + </div> + </template> + <template v-else-if="!policy && !showPolicyForm"> + <header class="ui header"> + <h3> + <i class="shield icon"></i> + <translate>You don't have any rule in place for this domain.</translate> + </h3> + </header> + <p><translate>Moderation policies help you control how your instance interact with a given domain or account.</translate></p> + <button @click="showPolicyForm = true" class="ui primary button">Add a moderation policy</button> + </template> + <instance-policy-card v-else-if="policy && !showPolicyForm" :object="policy" @update="showPolicyForm = true"> + <header class="ui header"> + <h3> + <translate>This domain is subject to specific moderation rules</translate> + </h3> + </header> + </instance-policy-card> + <instance-policy-form + v-else-if="showPolicyForm" + @cancel="showPolicyForm = false" + @save="updatePolicy" + @delete="policy = null; showPolicyForm = false" + :object="policy" + type="domain" + :target="object.name" /> </div> - </h2> + </div> </div> </section> <div class="ui vertical stripe segment"> @@ -244,15 +286,25 @@ import axios from "axios" import logger from "@/logging" import lodash from '@/lodash' +import InstancePolicyForm from "@/components/manage/moderation/InstancePolicyForm" +import InstancePolicyCard from "@/components/manage/moderation/InstancePolicyCard" + export default { props: ["id"], + components: { + InstancePolicyForm, + InstancePolicyCard, + }, data() { return { lodash, isLoading: true, isLoadingStats: false, + isLoadingPolicy: false, + policy: null, object: null, stats: null, + showPolicyForm: false, permissions: [], } }, @@ -268,6 +320,9 @@ export default { axios.get(url).then(response => { self.object = response.data self.isLoading = false + if (self.object.instance_policy) { + self.fetchPolicy(self.object.instance_policy) + } }) }, fetchStats() { @@ -279,10 +334,23 @@ export default { self.isLoadingStats = false }) }, + fetchPolicy(id) { + var self = this + this.isLoadingPolicy = true + let url = `manage/moderation/instance-policies/${id}/` + axios.get(url).then(response => { + self.policy = response.data + self.isLoadingPolicy = false + }) + }, refreshNodeInfo (data) { this.object.nodeinfo = data this.object.nodeinfo_fetch_date = new Date() }, + updatePolicy (policy) { + this.policy = policy + this.showPolicyForm = false + } }, computed: { labels() { @@ -299,4 +367,7 @@ export default { <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped> +.placeholder.segment { + width: 100%; +} </style>