Add markdown enhancements

This commit will bring:
- Linking to other users with `@username`
- Linking to tags with `#tag`
- Opening external links in new tab (Fix #1647)
- Single line breaks to avoid confusion for non-technical users (Fix #1377)
- 😒 support...
- Email encoding in markdown
- Markdown editor now auto-resizes to accomodate content (Fix #1379)

NOTE: This only works in very few places. We need to wait for #1835 to have those features available widely
environments/review-front-deve-otr6gc/deployments/13419
wvffle 2022-07-25 18:41:03 +00:00 zatwierdzone przez Georg Krause
rodzic 8aa073b976
commit f06c040b50
22 zmienionych plików z 1493 dodań i 714 usunięć

Wyświetl plik

@ -32,6 +32,8 @@ tasks:
poetry run python manage.py gitpod dev
- name: Frontend
env:
VUE_EDITOR: code
before: cd front
init: |
yarn install
@ -42,6 +44,7 @@ tasks:
env:
COMPOSE_FILE: /workspace/funkwhale/.gitpod/docker-compose.yml
ENV_FILE: /workspace/funkwhale/.gitpod/.env
VUE_EDITOR: code
command: |
clear
echo ""

Wyświetl plik

@ -18,31 +18,35 @@
"postinstall": "yarn run fix-fomantic-css"
},
"dependencies": {
"@tiptap/starter-kit": "^2.0.0-beta.191",
"@tiptap/vue-3": "^2.0.0-beta.96",
"@vue/runtime-core": "3.2.37",
"@vueuse/core": "8.9.4",
"@vueuse/integrations": "8.9.4",
"axios": "0.27.2",
"axios-auth-refresh": "3.3.1",
"axios-auth-refresh": "3.3.3",
"diff": "5.1.0",
"dompurify": "2.3.8",
"dompurify": "2.3.10",
"focus-trap": "6.9.4",
"fomantic-ui-css": "2.8.8",
"howler": "2.2.3",
"js-logger": "1.6.1",
"lodash-es": "4.17.21",
"mavon-editor": "^3.0.0-beta",
"moment": "2.29.4",
"qs": "6.11.0",
"register-service-worker": "1.7.2",
"sanitize-html": "2.7.1",
"sass": "1.53.0",
"sass": "1.54.0",
"showdown": "2.1.0",
"text-clipper": "2.2.0",
"tiptap-markdown": "^0.5.0",
"transliteration": "2.3.5",
"vue": "3.2.37",
"vue-gettext": "2.1.12",
"vue-plyr": "7.0.0",
"vue-router": "4.1.2",
"vue-tsc": "0.38.9",
"vue-tsc": "0.39.0",
"vue-upload-component": "3.1.2",
"vue-virtual-scroller": "^2.0.0-alpha.1",
"vue3-gettext": "2.3.0",
@ -66,7 +70,7 @@
"@typescript-eslint/eslint-plugin": "5.30.7",
"@vitejs/plugin-vue": "3.0.1",
"@vue/compiler-sfc": "3.2.37",
"@vue/eslint-config-standard": "7.0.0",
"@vue/eslint-config-standard": "8.0.0",
"@vue/eslint-config-typescript": "11.0.0",
"@vue/test-utils": "2.0.2",
"@vue/tsconfig": "0.1.3",
@ -79,14 +83,15 @@
"eslint-plugin-n": "15.2.4",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-promise": "6.0.0",
"eslint-plugin-vue": "9.2.0",
"eslint-plugin-vue": "9.3.0",
"jest-cli": "28.1.3",
"moxios": "0.4.0",
"sinon": "14.0.0",
"ts-jest": "28.0.7",
"typescript": "4.7.4",
"vite": "3.0.2",
"vite": "3.0.3",
"vite-plugin-pwa": "0.12.3",
"vite-plugin-vue-inspector": "1.0.1",
"vue-jest": "3.0.7",
"workbox-core": "6.5.3",
"workbox-precaching": "6.5.3",

Wyświetl plik

@ -1,13 +1,11 @@
<script setup lang="ts">
import { useStore } from '~/store'
import { get } from 'lodash-es'
import showdown from 'showdown'
import useMarkdown from '~/composables/useMarkdown'
import { humanSize } from '~/utils/filters'
import { computed } from 'vue'
import { useGettext } from 'vue3-gettext'
const markdown = new showdown.Converter()
const store = useStore()
const nodeinfo = computed(() => store.state.instance.nodeinfo)
@ -18,9 +16,9 @@ const labels = computed(() => ({
const podName = computed(() => get(nodeinfo.value, 'metadata.nodeName') || 'Funkwhale')
const banner = computed(() => get(nodeinfo.value, 'metadata.banner'))
const longDescription = computed(() => get(nodeinfo.value, 'metadata.longDescription'))
const rules = computed(() => get(nodeinfo.value, 'metadata.rules'))
const terms = computed(() => get(nodeinfo.value, 'metadata.terms'))
const longDescription = useMarkdown(() => get(nodeinfo.value, 'metadata.longDescription'))
const rules = useMarkdown(() => get(nodeinfo.value, 'metadata.rules'))
const terms = useMarkdown(() => get(nodeinfo.value, 'metadata.terms'))
const contactEmail = computed(() => get(nodeinfo.value, 'metadata.contactEmail'))
const anonymousCanListen = computed(() => get(nodeinfo.value, 'metadata.library.anonymousCanListen'))
const allowListEnabled = computed(() => get(nodeinfo.value, 'metadata.allowList.enabled'))
@ -140,7 +138,7 @@ const headerStyle = computed(() => {
</h2>
<sanitized-html
v-if="longDescription"
:html="markdown.makeHtml(longDescription)"
:html="longDescription"
/>
<p v-else>
<translate translate-context="Content/About/Paragraph">
@ -158,7 +156,7 @@ const headerStyle = computed(() => {
</h3>
<sanitized-html
v-if="rules"
:html="markdown.makeHtml(rules)"
:html="rules"
/>
<p v-else>
<translate translate-context="Content/About/Paragraph">
@ -176,7 +174,7 @@ const headerStyle = computed(() => {
</h3>
<sanitized-html
v-if="terms"
:html="markdown.makeHtml(terms)"
:html="terms"
/>
<p v-else>
<translate translate-context="Content/About/Paragraph">

Wyświetl plik

@ -1,10 +1,10 @@
<script setup lang="ts">
import { get } from 'lodash-es'
import showdown from 'showdown'
import AlbumWidget from '~/components/audio/album/Widget.vue'
import ChannelsWidget from '~/components/audio/ChannelsWidget.vue'
import LoginForm from '~/components/auth/LoginForm.vue'
import SignupForm from '~/components/auth/SignupForm.vue'
import useMarkdown from '~/composables/useMarkdown'
import { humanSize } from '~/utils/filters'
import { useStore } from '~/store'
import { computed } from 'vue'
@ -12,8 +12,6 @@ import { whenever } from '@vueuse/core'
import { useGettext } from 'vue3-gettext'
import { useRouter } from 'vue-router'
const markdown = new showdown.Converter()
const { $pgettext } = useGettext()
const labels = computed(() => ({
title: $pgettext('Head/Home/Title', 'Home')
@ -25,7 +23,7 @@ const nodeinfo = computed(() => store.state.instance.nodeinfo)
const podName = computed(() => get(nodeinfo.value, 'metadata.nodeName') || 'Funkwhale')
const banner = computed(() => get(nodeinfo.value, 'metadata.banner'))
const shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription'))
const longDescription = computed(() => get(nodeinfo.value, 'metadata.longDescription'))
const longDescription = useMarkdown(() => get(nodeinfo.value, 'metadata.longDescription'))
const rules = computed(() => get(nodeinfo.value, 'metadata.rules'))
const contactEmail = computed(() => get(nodeinfo.value, 'metadata.contactEmail'))
const anonymousCanListen = computed(() => get(nodeinfo.value, 'metadata.library.anonymousCanListen'))
@ -111,7 +109,7 @@ whenever(() => store.state.auth.authenticated, () => {
<sanitized-html
v-if="longDescription"
id="renderedDescription"
:html="markdown.makeHtml(longDescription)"
:html="longDescription"
/>
<div
v-if="longDescription"

Wyświetl plik

@ -11,6 +11,13 @@ const props = withDefaults(defineProps<Props>(), {
tag: 'div'
})
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
// set all elements owning target to target=_blank
if ('target' in node) {
node.setAttribute('target', '_blank')
}
})
const html = computed(() => DOMPurify.sanitize(props.html))
const root = () => h(props.tag, { innerHTML: html.value })
</script>

Wyświetl plik

@ -46,7 +46,7 @@ const fetchData = async (url = props.url) => {
count.value = response.data.count
const newObjects = !props.isActivity
? response.data.results.map((track: Track) => { track })
? response.data.results.map((track: Track) => ({ track }))
: response.data.results
objects.push(...newObjects)
@ -62,7 +62,7 @@ fetchData()
const emit = defineEmits(['count'])
watch(count, (to) => emit('count', to))
if (props.websocketHandlers.includes('Listen')) {
watch(() => props.websocketHandlers.includes('Listen'), (to) => {
useWebSocketHandler('Listen', (event) => {
// TODO (wvffle): Add reactivity to recently listened / favorited / added (#1316, #1534)
// count.value += 1
@ -70,7 +70,7 @@ if (props.websocketHandlers.includes('Listen')) {
// objects.unshift(event as Listening)
// objects.pop()
})
}
})
</script>
<template>

Wyświetl plik

@ -1,3 +1,57 @@
<script setup lang="ts">
import type { Library, Plugin, BackendError } from '~/types'
import axios from 'axios'
import { clone } from 'lodash-es'
import useMarkdown, { useMarkdownRaw } from '~/composables/useMarkdown'
import { ref } from 'vue'
interface Props {
plugin: Plugin
libraries: Library[]
}
const props = defineProps<Props>()
const description = useMarkdown(() => props.plugin.description ?? '')
const enabled = ref(props.plugin.enabled)
const values = clone(props.plugin.values ?? {})
const errors = ref([] as string[])
const isLoading = ref(false)
const submit = async () => {
isLoading.value = true
errors.value = []
try {
await axios.post(`plugins/${props.plugin.name}/${enabled.value ? 'enable' : 'disable'}`)
await axios.post(`plugins/${props.plugin.name}`, values)
} catch (error) {
errors.value = (error as BackendError).backendErrors
}
isLoading.value = false
}
const scan = async () => {
isLoading.value = true
errors.value = []
try {
await axios.post(`plugins/${props.plugin.name}/scan`, values)
} catch (error) {
errors.value = (error as BackendError).backendErrors
}
isLoading.value = false
}
const submitAndScan = async () => {
await submit()
await scan()
}
</script>
<template>
<form
:class="['ui segment form', {loading: isLoading}]"
@ -6,7 +60,7 @@
<h3>{{ plugin.label }}</h3>
<sanitized-html
v-if="plugin.description"
:html="markdown.makeHtml(plugin.description)"
:html="description"
/>
<template v-if="plugin.homepage">
<div class="ui small hidden divider" />
@ -74,8 +128,8 @@
</div>
<template v-if="plugin.conf?.length > 0">
<template
v-for="(field, key) in plugin.conf"
:key="key"
v-for="field in plugin.conf"
:key="field.name"
>
<div
v-if="field.type === 'text'"
@ -89,7 +143,7 @@
>
<sanitized-html
v-if="field.help"
:html="markdown.makeHtml(field.help)"
:html="useMarkdownRaw(field.help)"
/>
</div>
<div
@ -105,7 +159,7 @@
/>
<sanitized-html
v-if="field.help"
:html="markdown.makeHtml(field.help)"
:html="useMarkdownRaw(field.help)"
/>
</div>
<div
@ -120,7 +174,7 @@
>
<sanitized-html
v-if="field.help"
:html="markdown.makeHtml(field.help)"
:html="useMarkdownRaw(field.help)"
/>
</div>
<div
@ -135,7 +189,7 @@
>
<sanitized-html
v-if="field.help"
:html="markdown.makeHtml(field.help)"
:html="useMarkdownRaw(field.help)"
/>
</div>
</template>
@ -150,7 +204,6 @@
</button>
<button
v-if="plugin.source"
type="scan"
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
@click.prevent="submitAndScan"
>
@ -161,54 +214,3 @@
<div class="ui clearing hidden divider" />
</form>
</template>
<script>
import axios from 'axios'
import { clone } from 'lodash-es'
import showdown from 'showdown'
export default {
props: {
plugin: { type: Object, required: true },
libraries: { type: Array, required: true }
},
data () {
return {
markdown: new showdown.Converter(),
isLoading: false,
enabled: this.plugin.enabled,
values: clone(this.plugin.values || {}),
errors: []
}
},
methods: {
async submit () {
this.isLoading = true
this.errors = []
const url = `plugins/${this.plugin.name}`
const enableUrl = this.enabled ? `${url}/enable` : `${url}/disable`
await axios.post(enableUrl)
try {
await axios.post(url, this.values)
} catch (e) {
this.errors = e.backendErrors
}
this.isLoading = false
},
async scan () {
this.isLoading = true
this.errors = []
const url = `plugins/${this.plugin.name}/scan`
try {
await axios.post(url, this.values)
} catch (e) {
this.errors = e.backendErrors
}
this.isLoading = false
},
async submitAndScan () {
await this.submit()
await this.scan()
}
}
}
</script>

Wyświetl plik

@ -1,3 +1,82 @@
<script setup lang="ts">
import axios from 'axios'
import { useVModel, watchDebounced, useTextareaAutosize, syncRef } from '@vueuse/core'
import { ref, computed, watchEffect, onMounted, nextTick } from 'vue'
import { useGettext } from 'vue3-gettext'
interface Emits {
(e: 'update:modelValue', value: string): void
}
interface Props {
modelValue: string
placeholder?: string
autofocus?: boolean
permissive?: boolean
required?: boolean
charLimit?: number
}
const emit = defineEmits<Emits>()
const props = withDefaults(defineProps<Props>(), {
placeholder: undefined,
autofocus: false,
charLimit: 5000,
permissive: false,
required: false
})
const { $pgettext } = useGettext()
const { textarea, input } = useTextareaAutosize()
const value = useVModel(props, 'modelValue', emit)
syncRef(value, input)
const isPreviewing = ref(false)
const preview = ref()
const isLoadingPreview = ref(false)
const labels = computed(() => ({
placeholder: props.placeholder ?? $pgettext('*/Form/Placeholder', 'Write a few words here…')
}))
const remainingChars = computed(() => props.charLimit - props.modelValue.length)
const loadPreview = async () => {
isLoadingPreview.value = true
try {
const response = await axios.post('text-preview/', { text: value.value, permissive: props.permissive })
preview.value = response.data.rendered
} catch (error) {
console.error(error)
}
isLoadingPreview.value = false
}
watchDebounced(value, async () => {
await loadPreview()
}, { immediate: true, debounce: 500 })
watchEffect(async () => {
if (isPreviewing.value) {
if (value.value && !preview.value && !isLoadingPreview.value) {
await loadPreview()
}
return
}
await nextTick()
textarea.value.focus()
})
onMounted(async () => {
if (props.autofocus) {
await nextTick()
textarea.value.focus()
}
})
</script>
<template>
<div class="content-form ui segments">
<div class="ui segment">
@ -31,7 +110,7 @@
<div class="line" />
</div>
</div>
<p v-else-if="preview === null">
<p v-else-if="!preview">
<translate translate-context="*/Form/Paragraph">
Nothing to preview.
</translate>
@ -44,13 +123,10 @@
<template v-else>
<div class="ui transparent input">
<textarea
:id="fieldId"
ref="textarea"
v-model="newValue"
:name="fieldId"
:rows="rows"
:required="required || null"
:placeholder="placeholder || labels.placeholder"
v-model="value"
:required="required"
:placeholder="labels.placeholder"
/>
</div>
<div class="ui very small hidden divider" />
@ -71,82 +147,3 @@
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
props: {
modelValue: { type: String, default: '' },
fieldId: { type: String, default: 'change-content' },
placeholder: { type: String, default: null },
autofocus: { type: Boolean, default: false },
charLimit: { type: Number, default: 5000, required: false },
rows: { type: Number, default: 5, required: false },
permissive: { type: Boolean, default: false },
required: { type: Boolean, default: false }
},
data () {
return {
isPreviewing: false,
preview: null,
newValue: this.modelValue,
isLoadingPreview: false
}
},
computed: {
labels () {
return {
placeholder: this.$pgettext('*/Form/Placeholder', 'Write a few words here…')
}
},
remainingChars () {
return this.charLimit - (this.modelValue || '').length
}
},
watch: {
newValue (v) {
this.preview = null
this.$emit('update:modelValue', v)
},
modelValue: {
async handler (v) {
this.preview = null
this.newValue = v
if (this.isPreviewing) {
await this.loadPreview()
}
},
immediate: true
},
async isPreviewing (v) {
if (v && !!this.modelValue && this.preview === null && !this.isLoadingPreview) {
await this.loadPreview()
}
if (!v) {
await this.$nextTick()
this.$refs.textarea.focus()
}
}
},
mounted () {
if (this.autofocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
},
methods: {
async loadPreview () {
this.isLoadingPreview = true
try {
const response = await axios.post('text-preview/', { text: this.newValue, permissive: this.permissive })
this.preview = response.data.rendered
} catch (error) {
console.error(error)
}
this.isLoadingPreview = false
}
}
}
</script>

Wyświetl plik

@ -1,3 +1,17 @@
<script setup lang="ts">
import type { InstancePolicy } from '~/types'
import useMarkdown from '~/composables/useMarkdown'
interface Props {
object: InstancePolicy
}
const props = defineProps<Props>()
const summary = useMarkdown(() => props.object.summary)
</script>
<template>
<div>
<slot />
@ -64,10 +78,10 @@
</div>
</div>
</div>
<div v-if="markdown && object.summary">
<div v-if="summary">
<div class="ui hidden divider" />
<p><strong><translate translate-context="Content/Moderation/*/Noun">Reason</translate></strong></p>
<sanitized-html :html="markdown.makeHtml(object.summary)" />
<sanitized-html :html="summary" />
</div>
<div class="ui hidden divider" />
<button
@ -81,21 +95,3 @@
</button>
</div>
</template>
<script>
import showdown from 'showdown'
export default {
props: {
object: { type: Object, default: null }
},
data () {
return {
markdown: null
}
},
created () {
this.markdown = showdown.Converter({ simplifiedAutoLink: true, openLinksInNewWindow: true })
}
}
</script>

Wyświetl plik

@ -1,3 +1,50 @@
<script setup lang="ts">
import type { Note, BackendError } from '~/types'
import axios from 'axios'
import { ref, computed } from 'vue'
import { useGettext } from 'vue3-gettext'
interface Emits {
(e: 'created', note: Note): void
}
interface Props {
target: Note
}
const emit = defineEmits<Emits>()
const props = defineProps<Props>()
const { $pgettext } = useGettext()
const labels = computed(() => ({
summaryPlaceholder: $pgettext('Content/Moderation/Placeholder', 'Describe what actions have been taken, or any other related updates…')
}))
const summary = ref('')
const isLoading = ref(false)
const errors = ref([] as string[])
const submit = async () => {
isLoading.value = true
errors.value = []
try {
const response = await axios.post('manage/moderation/notes/', {
target: props.target,
summary: summary.value
})
emit('created', response.data)
summary.value = ''
} catch (error) {
errors.value = (error as BackendError).backendErrors
}
isLoading.value = false
}
</script>
<template>
<form
class="ui form"
@ -34,7 +81,7 @@
<button
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
type="submit"
:disabled="isLoading || null"
:disabled="isLoading"
>
<translate translate-context="Content/Moderation/Button.Label/Verb">
Add note
@ -42,48 +89,3 @@
</button>
</form>
</template>
<script>
import axios from 'axios'
import showdown from 'showdown'
export default {
props: {
target: { type: Object, required: true }
},
data () {
return {
markdown: new showdown.Converter(),
isLoading: false,
summary: '',
errors: []
}
},
computed: {
labels () {
return {
summaryPlaceholder: this.$pgettext('Content/Moderation/Placeholder', 'Describe what actions have been taken, or any other related updates…')
}
}
},
methods: {
submit () {
const self = this
this.isLoading = true
const payload = {
target: this.target,
summary: this.summary
}
this.errors = []
axios.post('manage/moderation/notes/', payload).then((response) => {
self.$emit('created', response.data)
self.summary = ''
self.isLoading = false
}, error => {
self.errors = error.backendErrors
self.isLoading = false
})
}
}
}
</script>

Wyświetl plik

@ -2,7 +2,7 @@
import type { Note } from '~/types'
import axios from 'axios'
import showdown from 'showdown'
import { useMarkdownRaw } from '~/composables/useMarkdown'
import { ref } from 'vue'
interface Props {
@ -11,8 +11,6 @@ interface Props {
defineProps<Props>()
const markdown = new showdown.Converter()
const emit = defineEmits(['deleted'])
const isLoading = ref(false)
const remove = async (note: Note) => {
@ -51,7 +49,7 @@ const remove = async (note: Note) => {
</div>
<div class="extra text">
<expandable-div :content="note.summary">
<sanitized-html :html="markdown.makeHtml(note.summary)" />
<sanitized-html :html="useMarkdownRaw(note.summary ?? '')" />
</expandable-div>
</div>
<div class="meta">

Wyświetl plik

@ -1,3 +1,138 @@
<script setup lang="ts">
import type { Report } from '~/types'
import axios from 'axios'
import useReportConfigs from '~/composables/moderation/useReportConfigs'
import useMarkdown from '~/composables/useMarkdown'
import { ref, computed, reactive } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useStore } from '~/store'
import NoteForm from '~/components/manage/moderation/NoteForm.vue'
import NotesThread from '~/components/manage/moderation/NotesThread.vue'
import ReportCategoryDropdown from '~/components/moderation/ReportCategoryDropdown.vue'
import InstancePolicyModal from '~/components/manage/moderation/InstancePolicyModal.vue'
interface Emits {
(e: 'updated', updating: { type: string }): void
(e: 'handled', isHandled: boolean): void
}
interface Props {
initObj: Report
}
const emit = defineEmits<Emits>()
const props = defineProps<Props>()
const configs = useReportConfigs()
const obj = ref(props.initObj)
const summary = useMarkdown(() => obj.value.summary ?? '')
const target = computed(() => obj.value.target
? obj.value.target
: obj.value.target_state._target
)
const targetFields = computed(() => {
if (!target.value) {
return []
}
const payload = obj.value.target_state
const fields = configs[target.value.type].moderatedFields
return fields.map((fieldConfig) => {
const getValueRepr = fieldConfig.getValueRepr ?? (i => i)
return {
id: fieldConfig.id,
label: fieldConfig.label,
value: payload[fieldConfig.id],
repr: getValueRepr(payload[fieldConfig.id]) ?? ''
}
})
})
const { $pgettext } = useGettext()
const actions = computed(() => {
if (!target.value) {
return []
}
const typeConfig = configs[target.value.type]
const deleteUrl = typeConfig.getDeleteUrl?.(target.value)
return deleteUrl
? [{
label: $pgettext('Content/Moderation/Button/Verb', 'Delete reported object'),
modalHeader: $pgettext('Content/Moderation/Popup/Header', 'Delete reported object?'),
modalContent: $pgettext('Content/Moderation/Popup,Paragraph', 'This will delete the object associated with this report and mark the report as resolved. The deletion is irreversible.'),
modalConfirmLabel: $pgettext('*/*/*/Verb', 'Delete'),
icon: 'x',
iconColor: 'danger',
show: (obj: Report) => { return !!obj.target },
dangerous: true,
handler: async () => {
try {
await axios.delete(deleteUrl)
console.log('Target deleted')
obj.value.target = undefined
resolveReport(true)
} catch (error) {
console.log('Error while deleting target', error)
// TODO (wvffle): Handle error
}
}
}]
: []
})
const isLoading = ref(false)
const updating = reactive({ type: false })
const update = async (type: string) => {
isLoading.value = true
updating.type = true
try {
await axios.patch(`manage/moderation/reports/${obj.value.uuid}/`, { type })
emit('updated', { type })
} catch (error) {
// TODO (wvffle): Handle error
}
updating.type = false
isLoading.value = false
}
const store = useStore()
const isCollapsed = ref(false)
const resolveReport = async (isHandled: boolean) => {
isLoading.value = true
try {
await axios.patch(`manage/moderation/reports/${obj.value.uuid}/`, { is_handled: isHandled })
emit('handled', isHandled)
obj.value.is_handled = isHandled
if (isHandled) {
isCollapsed.value = true
}
store.commit('ui/incrementNotifications', {
type: 'pendingReviewReports',
count: isHandled ? -1 : 1
})
} catch (error) {
// TODO (wvffle): Handle error
}
isLoading.value = false
}
const handleRemovedNote = (uuid: string) => {
obj.value.notes = obj.value.notes.filter((note) => note.uuid !== uuid)
}
</script>
<template>
<div class="ui fluid report card">
<div class="content">
@ -48,7 +183,7 @@
<td>
<report-category-dropdown
v-model="obj.type"
@update:model-value="update({ type: $event })"
@update:model-value="update($event)"
>
&#32;
<action-feedback :is-loading="updating.type" />
@ -163,11 +298,11 @@
</translate>
</h3>
<expandable-div
v-if="obj.summary"
v-if="summary"
class="summary"
:content="obj.summary"
>
<sanitized-html :html="markdown.makeHtml(obj.summary)" />
<sanitized-html :html="summary" />
</expandable-div>
</div>
<aside class="column">
@ -275,7 +410,7 @@
</tr>
<tr v-else-if="obj.target_state.domain">
<td>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: obj.target_state.domain }}">
<router-link :to="{name: 'manage.moderation.domains.detail', params: { id: obj.target_state.domain }}">
<translate translate-context="Content/Moderation/*/Noun">
Domain
</translate>
@ -342,7 +477,7 @@
<button
v-if="obj.is_handled === false"
:class="['ui', {loading: isLoading}, 'button']"
@click="resolve(true)"
@click="resolveReport(true)"
>
<i class="success check icon" />&nbsp;
<translate translate-context="Content/*/Button.Label/Verb">
@ -352,7 +487,7 @@
<button
v-if="obj.is_handled === true"
:class="['ui', {loading: isLoading}, 'button']"
@click="resolve(false)"
@click="resolveReport(false)"
>
<i class="warning redo icon" />&nbsp;
<translate translate-context="Content/*/Button.Label">
@ -389,174 +524,3 @@
</div>
</div>
</template>
<script>
import axios from 'axios'
import NoteForm from '~/components/manage/moderation/NoteForm.vue'
import NotesThread from '~/components/manage/moderation/NotesThread.vue'
import ReportCategoryDropdown from '~/components/moderation/ReportCategoryDropdown.vue'
import InstancePolicyModal from '~/components/manage/moderation/InstancePolicyModal.vue'
import useReportConfigs from '~/composables/moderation/useReportConfigs.ts'
import { setUpdate } from '~/utils'
import showdown from 'showdown'
function castValue (value) {
if (value === null || value === undefined) {
return ''
}
return String(value)
}
export default {
components: {
NoteForm,
NotesThread,
ReportCategoryDropdown,
InstancePolicyModal
},
props: {
initObj: { type: Object, required: true },
currentState: { type: String, required: false, default: '' }
},
setup () {
return { configs: useReportConfigs() }
},
data () {
return {
obj: this.initObj,
markdown: new showdown.Converter(),
isLoading: false,
isCollapsed: false,
updating: {
type: false
}
}
},
computed: {
previousState () {
if (this.obj.is_applied) {
// mutation was applied, we use the previous state that is stored
// on the mutation itself
return this.obj.previous_state
}
// mutation is not applied yet, so we use the current state that was
// passed to the component, if any
return this.currentState
},
detailUrl () {
if (!this.target) {
return ''
}
let namespace
const id = this.target.id
if (this.target.type === 'track') {
namespace = 'library.tracks.edit.detail'
}
if (this.target.type === 'album') {
namespace = 'library.albums.edit.detail'
}
if (this.target.type === 'artist') {
namespace = 'library.artists.edit.detail'
}
return this.$router.resolve({ name: namespace, params: { id, editId: this.obj.uuid } }).href
},
targetFields () {
if (!this.target) {
return []
}
const payload = this.obj.target_state
const fields = this.configs[this.target.type].moderatedFields
return fields.map((fieldConfig) => {
const getValueRepr = fieldConfig.getValueRepr ?? (i => i)
return {
id: fieldConfig.id,
label: fieldConfig.label,
value: payload[fieldConfig.id],
repr: castValue(getValueRepr(payload[fieldConfig.id]))
}
})
},
target () {
if (this.obj.target) {
return this.obj.target
} else {
return this.obj.target_state._target
}
},
actions () {
if (!this.target) {
return []
}
const self = this
const actions = []
const typeConfig = this.configs[this.target.type]
if (typeConfig.getDeleteUrl) {
const deleteUrl = typeConfig.getDeleteUrl(this.target)
actions.push({
label: this.$pgettext('Content/Moderation/Button/Verb', 'Delete reported object'),
modalHeader: this.$pgettext('Content/Moderation/Popup/Header', 'Delete reported object?'),
modalContent: this.$pgettext('Content/Moderation/Popup,Paragraph', 'This will delete the object associated with this report and mark the report as resolved. The deletion is irreversible.'),
modalConfirmLabel: this.$pgettext('*/*/*/Verb', 'Delete'),
icon: 'x',
iconColor: 'danger',
show: (obj) => { return !!obj.target },
dangerous: true,
handler: () => {
axios.delete(deleteUrl).then((response) => {
console.log('Target deleted')
self.obj.target = null
self.resolve(true)
}, () => {
console.log('Error while deleting target')
})
}
})
}
return actions
}
},
methods: {
update (payload) {
const url = `manage/moderation/reports/${this.obj.uuid}/`
const self = this
this.isLoading = true
setUpdate(payload, this.updating, true)
axios.patch(url, payload).then((response) => {
self.$emit('updated', payload)
Object.assign(self.obj, payload)
self.isLoading = false
setUpdate(payload, self.updating, false)
}, () => {
self.isLoading = false
setUpdate(payload, self.updating, false)
})
},
resolve (v) {
const url = `manage/moderation/reports/${this.obj.uuid}/`
const self = this
this.isLoading = true
axios.patch(url, { is_handled: v }).then((response) => {
self.$emit('handled', v)
self.isLoading = false
self.obj.is_handled = v
let increment
if (v) {
self.isCollapsed = true
increment = -1
} else {
increment = 1
}
self.$store.commit('ui/incrementNotifications', { count: increment, type: 'pendingReviewReports' })
}, () => {
self.isLoading = false
})
},
handleRemovedNote (uuid) {
this.obj.notes = this.obj.notes.filter((note) => {
return note.uuid !== uuid
})
}
}
}
</script>

Wyświetl plik

@ -1,3 +1,66 @@
<script setup lang="ts">
import type { UserRequest, UserRequestStatus } from '~/types'
import axios from 'axios'
import { ref } from 'vue'
import { useStore } from '~/store'
import NoteForm from '~/components/manage/moderation/NoteForm.vue'
import NotesThread from '~/components/manage/moderation/NotesThread.vue'
interface Emits {
(e: 'handled', status: UserRequestStatus): void
}
interface Props {
initObj: UserRequest
}
const emit = defineEmits<Emits>()
const props = defineProps<Props>()
const store = useStore()
const obj = ref(props.initObj)
const isCollapsed = ref(false)
const isLoading = ref(false)
const approve = async (isApproved: boolean) => {
isLoading.value = true
try {
const status = isApproved
? 'approved'
: 'refused'
await axios.patch(`manage/moderation/requests/${obj.value.uuid}/`, {
status
})
emit('handled', status)
if (isApproved) {
isCollapsed.value = true
}
store.commit('ui/incrementNotifications', {
type: 'pendingReviewRequests',
count: -1
})
} catch (error) {
// TODO (wvffle): Handle error
}
isLoading.value = false
}
const handleRemovedNote = (uuid: string) => {
obj.value.notes = obj.value.notes.filter((note) => note.uuid !== uuid)
}
const isArray = Array.isArray
</script>
<template>
<div class="ui fluid user-request card">
<div class="content">
@ -157,12 +220,12 @@
<template v-if="obj.metadata">
<div class="ui hidden divider" />
<div
v-for="k in Object.keys(obj.metadata)"
:key="k"
v-for="(value, key) in obj.metadata"
:key="key"
>
<h4>{{ k }}</h4>
<p v-if="obj.metadata[k] && obj.metadata[k].length">
{{ obj.metadata[k] }}
<h4>{{ key }}</h4>
<p v-if="isArray(value)">
{{ value }}
</p>
<translate
v-else
@ -222,52 +285,3 @@
</div>
</div>
</template>
<script>
import axios from 'axios'
import NoteForm from '~/components/manage/moderation/NoteForm.vue'
import NotesThread from '~/components/manage/moderation/NotesThread.vue'
import showdown from 'showdown'
export default {
components: {
NoteForm,
NotesThread
},
props: {
initObj: { type: Object, required: true }
},
data () {
return {
markdown: new showdown.Converter(),
isLoading: false,
isCollapsed: false,
obj: this.initObj
}
},
methods: {
approve (v) {
const url = `manage/moderation/requests/${this.obj.uuid}/`
const self = this
const newStatus = v ? 'approved' : 'refused'
this.isLoading = true
axios.patch(url, { status: newStatus }).then((response) => {
self.$emit('handled', newStatus)
self.isLoading = false
self.obj.status = newStatus
if (v) {
self.isCollapsed = true
}
self.$store.commit('ui/incrementNotifications', { count: -1, type: 'pendingReviewRequests' })
}, () => {
self.isLoading = false
})
},
handleRemovedNote (uuid) {
this.obj.notes = this.obj.notes.filter((note) => {
return note.uuid !== uuid
})
}
}
}
</script>

Wyświetl plik

@ -1,3 +1,4 @@
import type { EntityObjectType } from '~/types'
import type { RouteLocationRaw } from 'vue-router'
import { gettext } from '~/init/locale'
@ -19,7 +20,6 @@ export interface Entity {
moderatedFields: ModeratedField[]
}
export type EntityObjectType = 'artist' | 'album' | 'track' | 'library' | 'playlist' | 'account' | 'channel'
type Configs = Record<EntityObjectType, Entity>
const { $pgettext } = gettext

Wyświetl plik

@ -0,0 +1,55 @@
import type { MaybeComputedRef } from '@vueuse/core'
import { resolveUnref } from '@vueuse/core'
import { computed } from 'vue'
import showdown from 'showdown'
showdown.extension('openExternalInNewTab', {
type: 'output',
regex: /<a.+?href.+">/g,
replace (text: string) {
const matches = text.match(/href="(.+)">/) ?? []
const url = matches[1] ?? './'
if ((!url.startsWith('http://') && !url.startsWith('https://')) || url.startsWith('mailto:')) {
return text
}
const { hostname } = new URL(url)
return hostname !== location.hostname
? text.replace(matches[0], `href="${url}" target="_blank" rel="noopener noreferrer">`)
: text
}
})
showdown.extension('linkifyTags', {
type: 'language',
regex: /#[^\W]+/g,
replace (text: string) {
return `<a href="/library/tags/${text.slice(1)}">${text}</a>`
}
})
const markdown = new showdown.Converter({
extensions: ['openExternalInNewTab', 'linkifyTags'],
ghMentions: true,
ghMentionsLink: '/@{u}',
simplifiedAutoLink: true,
openLinksInNewWindow: false,
simpleLineBreaks: true,
strikethrough: true,
tables: true,
tasklists: true,
underline: true,
noHeaderId: true,
headerLevelStart: 3,
literalMidWordUnderscores: true,
excludeTrailingPunctuationFromURLs: true,
encodeEmails: true,
emoji: true
})
export const useMarkdownRaw = (md: string) => markdown.makeHtml(md)
export const useMarkdownComputed = (md: MaybeComputedRef<string>) => computed(() => useMarkdownRaw(resolveUnref(md)))
export default useMarkdownComputed

Wyświetl plik

@ -1,16 +1,29 @@
.content-form {
background: var(--input-background);
.segment {
background: none;
}
.segment:first-child {
min-height: 15em;
display: grid;
grid-template-rows: auto 1fr auto;
textarea {
height: 100%;
resize: none;
overflow-y: hidden;
max-height: none;
}
}
.ui.secondary.menu {
background: none;
margin-top: -0.5em;
}
.input {
width: 100%;
}

Wyświetl plik

@ -242,6 +242,10 @@ export interface PendingReviewRequestsWSEvent {
pending_count: number
}
export interface InboxItemAddedWSEvent {
item: Notification
}
export interface ListenWsEventObject {
local_id: string
}
@ -256,7 +260,7 @@ export interface ListenWSEvent {
// type: 'Listen'
// }
export type WebSocketEvent = PendingReviewEditsWSEvent | PendingReviewReportsWSEvent | PendingReviewRequestsWSEvent | ListenWSEvent
export type WebSocketEvent = PendingReviewEditsWSEvent | PendingReviewReportsWSEvent | PendingReviewRequestsWSEvent | ListenWSEvent | InboxItemAddedWSEvent
// FS Browser
export interface FSEntry {
@ -374,7 +378,96 @@ export interface SettingsDataEntry {
// Note stuff
export interface Note {
uuid: string
author: Actor // TODO (wvffle): Check if is valid
summary: string
creation_date: string
type: 'request' | 'report'
author?: Actor // TODO (wvffle): Check if is valid
summary?: string
creation_date?: string
}
// Instance policy stuff
export interface InstancePolicy {
id: number
uuid: string
creation_date: string
actor: Actor
summary: string
is_active: boolean
block_all: boolean
silence_activity: boolean
silence_notifications: boolean
reject_media: boolean
}
// Plugin stuff
export interface Plugin {
name: string
label: string
homepage?: string
enabled: boolean
description?: string
source?: string
values?: Record<string, string>
conf?: {
name: string
label: string
type: 'text' | 'long_text' | 'url' | 'password'
help?: string
}[]
}
// Report stuff
export type EntityObjectType = 'artist' | 'album' | 'track' | 'library' | 'playlist' | 'account' | 'channel'
export interface ReportTarget {
id: string
type: EntityObjectType
}
export interface Report {
uuid: string
summary?: string
is_applied: boolean
is_handled: boolean
previous_state: string
notes: Note[]
type: string
assigned_to?: Actor
submitter?: Actor
submitter_email?: string
target_owner?: Actor
target?: ReportTarget
target_state: {
_target: ReportTarget
domain: string
[k: string]: unknown
}
creation_date: string
handled_date: string
}
// User request stuff
export type UserRequestStatus = 'approved' | 'refused' | 'pending'
export interface UserRequest {
uuid: string
notes: Note[]
status: UserRequestStatus
assigned_to?: Actor
submitter?: Actor
submitter_email?: string
creation_date: string
handled_date: string
metadata: object
}
// Notification stuff
export interface Notification {
id: number
is_read: boolean
}

Wyświetl plik

@ -4,12 +4,6 @@ import type { Router } from 'vue-router'
import type { APIErrorResponse } from '~/types'
import type { RootState } from '~/store'
export function setUpdate (obj: object, statuses: Record<string, unknown>, value: unknown) {
for (const key of Object.keys(obj)) {
statuses[key] = value
}
}
export function parseAPIErrors (responseData: APIErrorResponse, parentField?: string): string[] {
const errors = []
for (const [field, value] of Object.entries(responseData)) {

Wyświetl plik

@ -1,3 +1,92 @@
<script setup lang="ts">
import type { Notification, InboxItemAddedWSEvent } from '~/types'
import axios from 'axios'
import moment from 'moment'
import { ref, reactive, computed, watch, markRaw } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useStore } from '~/store'
import useMarkdown from '~/composables/useMarkdown'
import useWebSocketHandler from '~/composables/useWebSocketHandler'
import NotificationRow from '~/components/notifications/NotificationRow.vue'
const store = useStore()
const supportMessage = useMarkdown(() => store.state.instance.settings.instance.support_message.value)
const { $pgettext } = useGettext()
const additionalNotifications = computed(() => store.getters['ui/additionalNotifications'])
const showInstanceSupportMessage = computed(() => store.getters['ui/showInstanceSupportMessage'])
const showFunkwhaleSupportMessage = computed(() => store.getters['ui/showFunkwhaleSupportMessage'])
const labels = computed(() => ({
title: $pgettext('*/Notifications/*', 'Notifications')
}))
const filters = reactive({
is_read: false
})
const isLoading = ref(false)
const notifications = reactive({ count: 0, results: [] as Notification[] })
const fetchData = async () => {
isLoading.value = true
try {
const response = await axios.get('federation/inbox/', { params: filters })
notifications.count = response.data.count
notifications.results = response.data.results.map(markRaw)
} catch (error) {
// TODO (wvffle): Handle error
}
isLoading.value = false
}
watch(filters, fetchData, { immediate: true })
useWebSocketHandler('inbox.item_added', (event) => {
notifications.count += 1
notifications.results.unshift(markRaw((event as InboxItemAddedWSEvent).item))
})
const instanceSupportMessageDelay = ref(60)
const funkwhaleSupportMessageDelay = ref(60)
const setDisplayDate = async (field: string, days: number) => {
try {
const response = await axios.patch(`users/${store.state.auth.username}/`, {
[field]: days
? moment().add({ days })
: undefined
})
store.commit('auth/profilePartialUpdate', response.data)
} catch (error) {
// TODO (wvffle): Handle error
}
}
const markAllAsRead = async () => {
try {
await axios.post('federation/inbox/action/', {
action: 'read',
objects: 'all',
filters: {
is_read: false,
before: notifications.results[0]?.id
}
})
store.commit('ui/notifications', { type: 'inbox', count: 0 })
notifications.results = notifications.results.map(notification => ({ ...notification, is_read: true }))
} catch (error) {
// TODO (wvffle): Handle error
}
}
</script>
<template>
<main
v-title="labels.title"
@ -25,7 +114,7 @@
Support this Funkwhale pod
</translate>
</h4>
<sanitized-html :html="markdown.makeHtml($store.state.instance.settings.instance.support_message.value)" />
<sanitized-html :html="supportMessage" />
</div>
<div class="ui bottom attached segment">
<form
@ -210,104 +299,3 @@
</section>
</main>
</template>
<script>
import { mapState, mapGetters } from 'vuex'
import axios from 'axios'
import showdown from 'showdown'
import moment from 'moment'
import NotificationRow from '~/components/notifications/NotificationRow.vue'
export default {
components: {
NotificationRow
},
data () {
return {
isLoading: false,
markdown: new showdown.Converter(),
notifications: { count: 0, results: [] },
instanceSupportMessageDelay: 60,
funkwhaleSupportMessageDelay: 60,
filters: {
is_read: false
}
}
},
computed: {
...mapGetters({
additionalNotifications: 'ui/additionalNotifications',
showInstanceSupportMessage: 'ui/showInstanceSupportMessage',
showFunkwhaleSupportMessage: 'ui/showFunkwhaleSupportMessage'
}),
labels () {
return {
title: this.$pgettext('*/Notifications/*', 'Notifications')
}
}
},
watch: {
'filters.is_read' () {
this.fetch(this.filters)
}
},
created () {
this.fetch(this.filters)
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'inbox.item_added',
id: 'notificationPage',
handler: this.handleNewNotification
})
},
unmounted () {
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'inbox.item_added',
id: 'notificationPage'
})
},
methods: {
handleNewNotification (event) {
this.notifications.count += 1
this.notifications.results.unshift(event.item)
},
setDisplayDate (field, days) {
const payload = {}
let newDisplayDate
if (days) {
newDisplayDate = moment().add({ days })
} else {
newDisplayDate = null
}
payload[field] = newDisplayDate
axios.patch(`users/${this.$store.state.auth.username}/`, payload).then((response) => {
this.$store.commit('auth/profilePartialUpdate', response.data)
})
},
fetch (params) {
this.isLoading = true
axios.get('federation/inbox/', { params }).then(response => {
this.isLoading = false
this.notifications = response.data
})
},
markAllAsRead () {
const before = this.notifications.results[0].id
const payload = {
action: 'read',
objects: 'all',
filters: {
is_read: false,
before
}
}
axios.post('federation/inbox/action/', payload).then(response => {
this.$store.commit('ui/notifications', { type: 'inbox', count: 0 })
this.notifications.results.forEach(n => {
n.is_read = true
})
})
}
}
}
</script>

Wyświetl plik

@ -8,7 +8,7 @@
</div>
<template v-if="object">
<div class="ui vertical stripe segment">
<report-card :obj="object" />
<report-card :init-obj="object" />
</div>
</template>
</main>

Wyświetl plik

@ -1,7 +1,6 @@
// import type { HmrOptions } from 'vite'
import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'
import Inspector from 'vite-plugin-vue-inspector'
import { VitePWA } from 'vite-plugin-pwa'
import { resolve } from 'path'
@ -22,16 +21,21 @@ export default defineConfig(() => ({
}
}),
// https://github.com/webfansplz/vite-plugin-vue-inspector
Inspector({
toggleComboKey: 'alt-shift-d'
}),
// https://github.com/antfu/vite-plugin-pwa
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'serviceWorker.ts',
manifestFilename: 'manifest.json',
devOptions: {
enabled: true,
type: 'module',
navigateFallback: 'index.html',
webManifestUrl: '/front/manifest.json'
navigateFallback: 'index.html'
}
})
],

Plik diff jest za duży Load Diff