diff --git a/api/funkwhale_api/favorites/serializers.py b/api/funkwhale_api/favorites/serializers.py index 66e10a1b49275781fc5b68d785b613b6b46a2079..dd28dcd07cbaff216d9988f9641d890f069b0d50 100644 --- a/api/funkwhale_api/favorites/serializers.py +++ b/api/funkwhale_api/favorites/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from funkwhale_api.activity import serializers as activity_serializers +from funkwhale_api.federation import serializers as federation_serializers from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer @@ -27,10 +28,17 @@ class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer): class UserTrackFavoriteSerializer(serializers.ModelSerializer): track = TrackSerializer(read_only=True) user = UserBasicSerializer(read_only=True) + actor = serializers.SerializerMethodField() class Meta: model = models.TrackFavorite - fields = ("id", "user", "track", "creation_date") + fields = ("id", "user", "track", "creation_date", "actor") + actor = serializers.SerializerMethodField() + + def get_actor(self, obj): + actor = obj.user.actor + if actor: + return federation_serializers.APIActorSerializer(actor).data class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer): diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py index dce285d85c65061fe81a86ceaa1886a6efe35ee7..7d1991bc67ebbba7f22932f91301265308ff9f32 100644 --- a/api/funkwhale_api/favorites/views.py +++ b/api/funkwhale_api/favorites/views.py @@ -22,7 +22,7 @@ class TrackFavoriteViewSet( filterset_class = filters.TrackFavoriteFilter serializer_class = serializers.UserTrackFavoriteSerializer - queryset = models.TrackFavorite.objects.all().select_related("user") + queryset = models.TrackFavorite.objects.all().select_related("user__actor") permission_classes = [ oauth_permissions.ScopePermission, permissions.OwnerPermission, @@ -54,7 +54,7 @@ class TrackFavoriteViewSet( ) tracks = Track.objects.with_playable_uploads( music_utils.get_actor_from_request(self.request) - ).select_related("artist", "album__artist") + ).select_related("artist", "album__artist", "attributed_to") queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks)) return queryset diff --git a/api/funkwhale_api/history/serializers.py b/api/funkwhale_api/history/serializers.py index 2254aee8cee370f4124c82acf59ad4c2c0d3148f..c894ec59ab093026f014b467dadf2f2f8db1af79 100644 --- a/api/funkwhale_api/history/serializers.py +++ b/api/funkwhale_api/history/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from funkwhale_api.activity import serializers as activity_serializers +from funkwhale_api.federation import serializers as federation_serializers from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer @@ -27,16 +28,22 @@ class ListeningActivitySerializer(activity_serializers.ModelSerializer): class ListeningSerializer(serializers.ModelSerializer): track = TrackSerializer(read_only=True) user = UserBasicSerializer(read_only=True) + actor = serializers.SerializerMethodField() class Meta: model = models.Listening - fields = ("id", "user", "track", "creation_date") + fields = ("id", "user", "track", "creation_date", "actor") def create(self, validated_data): validated_data["user"] = self.context["user"] return super().create(validated_data) + def get_actor(self, obj): + actor = obj.user.actor + if actor: + return federation_serializers.APIActorSerializer(actor).data + class ListeningWriteSerializer(serializers.ModelSerializer): class Meta: diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py index 30219629a4b0c5a2852d0cff1f9f04605604a654..6cdbc8a80f848c074adde2dee2f04f4ff5eefec1 100644 --- a/api/funkwhale_api/history/views.py +++ b/api/funkwhale_api/history/views.py @@ -19,7 +19,7 @@ class ListeningViewSet( ): serializer_class = serializers.ListeningSerializer - queryset = models.Listening.objects.all().select_related("user") + queryset = models.Listening.objects.all().select_related("user__actor") permission_classes = [ oauth_permissions.ScopePermission, @@ -47,7 +47,7 @@ class ListeningViewSet( ) tracks = Track.objects.with_playable_uploads( music_utils.get_actor_from_request(self.request) - ).select_related("artist", "album__artist") + ).select_related("artist", "album__artist", "attributed_to") return queryset.prefetch_related(Prefetch("track", queryset=tracks)) def get_serializer_context(self): diff --git a/api/funkwhale_api/instance/nodeinfo.py b/api/funkwhale_api/instance/nodeinfo.py index 178a8c1ab0c5a91be4077f695942e5d363b97a8e..ecdca9e4078e3eaecc4e0031b1e866304c02741d 100644 --- a/api/funkwhale_api/instance/nodeinfo.py +++ b/api/funkwhale_api/instance/nodeinfo.py @@ -3,6 +3,7 @@ import memoize.djangocache import funkwhale_api from funkwhale_api.common import preferences from funkwhale_api.federation import actors, models as federation_models +from funkwhale_api.moderation import models as moderation_models from funkwhale_api.music import utils as music_utils from . import stats @@ -15,6 +16,9 @@ def get(): share_stats = preferences.get("instance__nodeinfo_stats_enabled") allow_list_enabled = preferences.get("moderation__allow_list_enabled") allow_list_public = preferences.get("moderation__allow_list_public") + unauthenticated_report_types = preferences.get( + "moderation__unauthenticated_report_types" + ) if allow_list_enabled and allow_list_public: allowed_domains = list( federation_models.Domain.objects.filter(allowed=True) @@ -47,6 +51,10 @@ def get(): }, "supportedUploadExtensions": music_utils.SUPPORTED_EXTENSIONS, "allowList": {"enabled": allow_list_enabled, "domains": allowed_domains}, + "reportTypes": [ + {"type": t, "label": l, "anonymous": t in unauthenticated_report_types} + for t, l in moderation_models.REPORT_TYPES + ], }, } if share_stats: diff --git a/api/funkwhale_api/moderation/models.py b/api/funkwhale_api/moderation/models.py index 5a4081b7b6ea4528aae1bbd564a62504d4d970b1..c2b91760d916dd62f4eb59dc4e21ac6a3f2b89cc 100644 --- a/api/funkwhale_api/moderation/models.py +++ b/api/funkwhale_api/moderation/models.py @@ -115,7 +115,7 @@ REPORT_TYPES = [ class Report(federation_models.FederationMixin): uuid = models.UUIDField(default=uuid.uuid4, unique=True) creation_date = models.DateTimeField(default=timezone.now) - summary = models.TextField(null=True, max_length=50000) + summary = models.TextField(null=True, blank=True, max_length=50000) handled_date = models.DateTimeField(null=True) is_handled = models.BooleanField(default=False) type = models.CharField(max_length=40, choices=REPORT_TYPES) diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py index ccdf82f4b94fcb11ce5b3d16f1aa96db47a4e7db..dc61950dde7cd2f6d5268e8400284740a2d18449 100644 --- a/api/funkwhale_api/playlists/serializers.py +++ b/api/funkwhale_api/playlists/serializers.py @@ -2,6 +2,7 @@ from django.db import transaction from rest_framework import serializers from funkwhale_api.common import preferences +from funkwhale_api.federation import serializers as federation_serializers from funkwhale_api.music.models import Track from funkwhale_api.music.serializers import TrackSerializer from funkwhale_api.users.serializers import UserBasicSerializer @@ -79,6 +80,7 @@ class PlaylistSerializer(serializers.ModelSerializer): album_covers = serializers.SerializerMethodField(read_only=True) user = UserBasicSerializer(read_only=True) is_playable = serializers.SerializerMethodField() + actor = serializers.SerializerMethodField() class Meta: model = models.Playlist @@ -93,9 +95,15 @@ class PlaylistSerializer(serializers.ModelSerializer): "album_covers", "duration", "is_playable", + "actor", ) read_only_fields = ["id", "modification_date", "creation_date"] + def get_actor(self, obj): + actor = obj.user.actor + if actor: + return federation_serializers.APIActorSerializer(actor).data + def get_is_playable(self, obj): try: return bool(obj.playable_plts) diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py index 861dc81755f6007ade9f47df442c92d110646363..6f9ea23cecac6a997decfdf701b2cfada3fec731 100644 --- a/api/funkwhale_api/playlists/views.py +++ b/api/funkwhale_api/playlists/views.py @@ -23,7 +23,7 @@ class PlaylistViewSet( serializer_class = serializers.PlaylistSerializer queryset = ( models.Playlist.objects.all() - .select_related("user") + .select_related("user__actor") .annotate(tracks_count=Count("playlist_tracks")) .with_covers() .with_duration() diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py index 190c7918439f4acd73dce5b4e9d599ad3d979673..b81006386ac86b79a3ac34f37e19ffb89c153c40 100644 --- a/api/tests/favorites/test_favorites.py +++ b/api/tests/favorites/test_favorites.py @@ -4,8 +4,7 @@ import pytest from django.urls import reverse from funkwhale_api.favorites.models import TrackFavorite -from funkwhale_api.music import serializers as music_serializers -from funkwhale_api.users import serializers as users_serializers +from funkwhale_api.favorites import serializers def test_user_can_add_favorite(factories): @@ -20,22 +19,15 @@ def test_user_can_add_favorite(factories): def test_user_can_get_his_favorites( api_request, factories, logged_in_api_client, client ): - r = api_request.get("/") + request = api_request.get("/") favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user) factories["favorites.TrackFavorite"]() url = reverse("api:v1:favorites:tracks-list") response = logged_in_api_client.get(url, {"user": logged_in_api_client.user.pk}) expected = [ - { - "user": users_serializers.UserBasicSerializer( - favorite.user, context={"request": r} - ).data, - "track": music_serializers.TrackSerializer( - favorite.track, context={"request": r} - ).data, - "id": favorite.id, - "creation_date": favorite.creation_date.isoformat().replace("+00:00", "Z"), - } + serializers.UserTrackFavoriteSerializer( + favorite, context={"request": request} + ).data ] assert response.status_code == 200 assert response.data["results"] == expected diff --git a/api/tests/favorites/test_serializers.py b/api/tests/favorites/test_serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..16823caa34441d30f05f329f271199b689e21104 --- /dev/null +++ b/api/tests/favorites/test_serializers.py @@ -0,0 +1,20 @@ +from funkwhale_api.federation import serializers as federation_serializers +from funkwhale_api.favorites import serializers +from funkwhale_api.music import serializers as music_serializers +from funkwhale_api.users import serializers as users_serializers + + +def test_track_favorite_serializer(factories, to_api_date): + favorite = factories["favorites.TrackFavorite"]() + actor = favorite.user.create_actor() + + expected = { + "id": favorite.pk, + "creation_date": to_api_date(favorite.creation_date), + "track": music_serializers.TrackSerializer(favorite.track).data, + "actor": federation_serializers.APIActorSerializer(actor).data, + "user": users_serializers.UserBasicSerializer(favorite.user).data, + } + serializer = serializers.UserTrackFavoriteSerializer(favorite) + + assert serializer.data == expected diff --git a/api/tests/history/test_serializers.py b/api/tests/history/test_serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..170b44d6b11fa62b650f6f4a932db26b68873b6a --- /dev/null +++ b/api/tests/history/test_serializers.py @@ -0,0 +1,20 @@ +from funkwhale_api.federation import serializers as federation_serializers +from funkwhale_api.history import serializers +from funkwhale_api.music import serializers as music_serializers +from funkwhale_api.users import serializers as users_serializers + + +def test_listening_serializer(factories, to_api_date): + listening = factories["history.Listening"]() + actor = listening.user.create_actor() + + expected = { + "id": listening.pk, + "creation_date": to_api_date(listening.creation_date), + "track": music_serializers.TrackSerializer(listening.track).data, + "actor": federation_serializers.APIActorSerializer(actor).data, + "user": users_serializers.UserBasicSerializer(listening.user).data, + } + serializer = serializers.ListeningSerializer(listening) + + assert serializer.data == expected diff --git a/api/tests/instance/test_nodeinfo.py b/api/tests/instance/test_nodeinfo.py index 211dbaa54dbeb832244a0dbacade8b615d64cfa9..cdb9ad93a651388c4d8eba00e37daad3f2bc5217 100644 --- a/api/tests/instance/test_nodeinfo.py +++ b/api/tests/instance/test_nodeinfo.py @@ -8,6 +8,12 @@ from funkwhale_api.music import utils as music_utils def test_nodeinfo_dump(preferences, mocker): preferences["instance__nodeinfo_stats_enabled"] = True + preferences["moderation__unauthenticated_report_types"] = [ + "takedown_request", + "other", + "other_category_that_doesnt_exist", + ] + stats = { "users": {"total": 1, "active_halfyear": 12, "active_month": 13}, "tracks": 2, @@ -51,6 +57,29 @@ def test_nodeinfo_dump(preferences, mocker): }, "supportedUploadExtensions": music_utils.SUPPORTED_EXTENSIONS, "allowList": {"enabled": False, "domains": None}, + "reportTypes": [ + { + "type": "takedown_request", + "label": "Takedown request", + "anonymous": True, + }, + { + "type": "invalid_metadata", + "label": "Invalid metadata", + "anonymous": False, + }, + { + "type": "illegal_content", + "label": "Illegal content", + "anonymous": False, + }, + { + "type": "offensive_content", + "label": "Offensive content", + "anonymous": False, + }, + {"type": "other", "label": "Other", "anonymous": True}, + ], }, } assert nodeinfo.get() == expected @@ -58,6 +87,10 @@ def test_nodeinfo_dump(preferences, mocker): def test_nodeinfo_dump_stats_disabled(preferences, mocker): preferences["instance__nodeinfo_stats_enabled"] = False + preferences["moderation__unauthenticated_report_types"] = [ + "takedown_request", + "other", + ] expected = { "version": "2.0", @@ -83,6 +116,29 @@ def test_nodeinfo_dump_stats_disabled(preferences, mocker): }, "supportedUploadExtensions": music_utils.SUPPORTED_EXTENSIONS, "allowList": {"enabled": False, "domains": None}, + "reportTypes": [ + { + "type": "takedown_request", + "label": "Takedown request", + "anonymous": True, + }, + { + "type": "invalid_metadata", + "label": "Invalid metadata", + "anonymous": False, + }, + { + "type": "illegal_content", + "label": "Illegal content", + "anonymous": False, + }, + { + "type": "offensive_content", + "label": "Offensive content", + "anonymous": False, + }, + {"type": "other", "label": "Other", "anonymous": True}, + ], }, } assert nodeinfo.get() == expected diff --git a/api/tests/playlists/test_serializers.py b/api/tests/playlists/test_serializers.py index 2500947292142b653e99886144c8020788ad5762..f84df4bb23fbc7662eb7ea27b644ae3970ef1298 100644 --- a/api/tests/playlists/test_serializers.py +++ b/api/tests/playlists/test_serializers.py @@ -1,4 +1,6 @@ +from funkwhale_api.federation import serializers as federation_serializers from funkwhale_api.playlists import models, serializers +from funkwhale_api.users import serializers as users_serializers def test_cannot_max_500_tracks_per_playlist(factories, preferences): @@ -124,3 +126,25 @@ def test_playlist_serializer_include_duration(factories, api_request): serializer = serializers.PlaylistSerializer(qs.get()) assert serializer.data["duration"] == 45 + + +def test_playlist_serializer(factories, to_api_date): + playlist = factories["playlists.Playlist"]() + actor = playlist.user.create_actor() + + expected = { + "id": playlist.pk, + "name": playlist.name, + "privacy_level": playlist.privacy_level, + "is_playable": None, + "creation_date": to_api_date(playlist.creation_date), + "modification_date": to_api_date(playlist.modification_date), + "actor": federation_serializers.APIActorSerializer(actor).data, + "user": users_serializers.UserBasicSerializer(playlist.user).data, + "duration": 0, + "tracks_count": 0, + "album_covers": [], + } + serializer = serializers.PlaylistSerializer(playlist) + + assert serializer.data == expected diff --git a/front/src/App.vue b/front/src/App.vue index e401d475e05955a42eb24c0625325306b1d9e956..ad64cb0b5467523b03f987b6355a9e061ba72212 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -21,6 +21,7 @@ ></app-footer> <playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal> <filter-modal v-if="$store.state.auth.authenticated"></filter-modal> + <report-modal></report-modal> <shortcuts-modal @update:show="showShortcutsModal = $event" :show="showShortcutsModal"></shortcuts-modal> <GlobalEvents @keydown.h.exact="showShortcutsModal = !showShortcutsModal"/> </template> @@ -41,6 +42,7 @@ import moment from 'moment' import locales from './locales' import PlaylistModal from '@/components/playlists/PlaylistModal' import FilterModal from '@/components/moderation/FilterModal' +import ReportModal from '@/components/moderation/ReportModal' import ShortcutsModal from '@/components/ShortcutsModal' import SetInstanceModal from '@/components/SetInstanceModal' @@ -50,6 +52,7 @@ export default { Sidebar, AppFooter, FilterModal, + ReportModal, PlaylistModal, ShortcutsModal, GlobalEvents, diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 8e490b4f4d8cce0e68ea1fa6d1ed3fabe42903b1..cbc1da119bf286307e8d79391de0c1adf34ba865 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -27,9 +27,17 @@ <button v-if="track" class="item basic" :disabled="!playable" @click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track.id})" :title="labels.startRadio"> <i class="feed icon"></i><translate translate-context="*/Queue/Button.Label/Short, Verb">Start radio</translate> </button> + <div class="divider"></div> <button v-if="filterableArtist" class="item basic" :disabled="!filterableArtist" @click.stop.prevent="filterArtist" :title="labels.hideArtist"> <i class="eye slash outline icon"></i><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Hide content from this artist</translate> </button> + <button + v-for="obj in getReportableObjs({track, album, artist, playlist, account})" + :key="obj.target.type + obj.target.id" + class="item basic" + @click.stop.prevent="$store.dispatch('moderation/report', obj.target)"> + <i class="share icon" /> {{ obj.label }} + </button> </div> </div> </span> @@ -39,11 +47,15 @@ import axios from 'axios' import jQuery from 'jquery' +import ReportMixin from '@/components/mixins/Report' + export default { + mixins: [ReportMixin], props: { // we can either have a single or multiple tracks to play when clicked tracks: {type: Array, required: false}, track: {type: Object, required: false}, + account: {type: Object, required: false}, dropdownIconClasses: {type: Array, required: false, default: () => { return ['dropdown'] }}, playIconClass: {type: String, required: false, default: 'play icon'}, buttonClasses: {type: Array, required: false, default: () => { return ['button'] }}, @@ -79,7 +91,8 @@ export default { addToQueue: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Add to current queue'), playNext: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play next'), startRadio: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play similar songs'), - replacePlay: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Replace current queue') + replacePlay: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Replace current queue'), + report: this.$pgettext('*/Moderation/*/Button/Label,Verb', 'Report…'), } }, title () { @@ -118,7 +131,7 @@ export default { if (this.artist) { return this.artist } - } + }, }, methods: { diff --git a/front/src/components/audio/track/Widget.vue b/front/src/components/audio/track/Widget.vue index ecb967ab82a1cca1db1de7304a8992e750a71956..788d279d0f893a236a3d08d336fb27a8e77fceaf 100644 --- a/front/src/components/audio/track/Widget.vue +++ b/front/src/components/audio/track/Widget.vue @@ -37,7 +37,12 @@ </div> </div> <div class="one wide stretched column"> - <play-button class="basic icon" :dropdown-only="true" :dropdown-icon-classes="['ellipsis', 'vertical', 'large', 'grey']" :track="object.track"></play-button> + <play-button + class="basic icon" + :account="object.actor" + :dropdown-only="true" + :dropdown-icon-classes="['ellipsis', 'vertical', 'large', 'grey']" + :track="object.track"></play-button> </div> </div> </div> diff --git a/front/src/components/library/AlbumBase.vue b/front/src/components/library/AlbumBase.vue index 9f050e286d0be68f3dacbe0e5d6bc95d8b4cd294..e42f3e826be6cec0707577b85203af0d2e6c71c6 100644 --- a/front/src/components/library/AlbumBase.vue +++ b/front/src/components/library/AlbumBase.vue @@ -74,6 +74,15 @@ <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> </router-link> <div class="divider"></div> + <div + role="button" + class="basic item" + v-for="obj in getReportableObjs({album: object})" + :key="obj.target.type + obj.target.id" + @click.stop.prevent="$store.dispatch('moderation/report', obj.target)"> + <i class="share icon" /> {{ obj.label }} + </div> + <div class="divider"></div> <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.albums.detail', params: {id: object.id}}"> <i class="wrench icon"></i> <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> @@ -105,6 +114,7 @@ import PlayButton from "@/components/audio/PlayButton" import EmbedWizard from "@/components/audio/EmbedWizard" import Modal from '@/components/semantic/Modal' import TagsList from "@/components/tags/List" +import ReportMixin from '@/components/mixins/Report' const FETCH_URL = "albums/" @@ -121,6 +131,7 @@ function groupByDisc(acc, track) { } export default { + mixins: [ReportMixin], props: ["id"], components: { PlayButton, diff --git a/front/src/components/library/ArtistBase.vue b/front/src/components/library/ArtistBase.vue index 602423f70f16f413fcedfd24022d39d9affc0b67..2c5d5284aac8b1c39016f854830795a9de785e9b 100644 --- a/front/src/components/library/ArtistBase.vue +++ b/front/src/components/library/ArtistBase.vue @@ -84,6 +84,16 @@ <i class="edit icon"></i> <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> </router-link> + <div class="divider"></div> + <div + role="button" + class="basic item" + v-for="obj in getReportableObjs({artist: object})" + :key="obj.target.type + obj.target.id" + @click.stop.prevent="$store.dispatch('moderation/report', obj.target)"> + <i class="share icon" /> {{ obj.label }} + </div> + <div class="divider"></div> <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.artists.detail', params: {id: object.id}}"> <i class="wrench icon"></i> @@ -125,12 +135,12 @@ import EmbedWizard from "@/components/audio/EmbedWizard" import Modal from '@/components/semantic/Modal' import RadioButton from "@/components/radios/Button" import TagsList from "@/components/tags/List" +import ReportMixin from '@/components/mixins/Report' const FETCH_URL = "albums/" - - export default { + mixins: [ReportMixin], props: ["id"], components: { PlayButton, diff --git a/front/src/components/library/TrackBase.vue b/front/src/components/library/TrackBase.vue index 17a739d4290a017c7b88e881a13f5ea1d1762432..82f3aa8a7b4c548b9cc7a38ac43f3f1f0f5684e3 100644 --- a/front/src/components/library/TrackBase.vue +++ b/front/src/components/library/TrackBase.vue @@ -90,6 +90,15 @@ <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> </router-link> <div class="divider"></div> + <div + role="button" + class="basic item" + v-for="obj in getReportableObjs({track})" + :key="obj.target.type + obj.target.id" + @click.stop.prevent="$store.dispatch('moderation/report', obj.target)"> + <i class="share icon" /> {{ obj.label }} + </div> + <div class="divider"></div> <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.tracks.detail', params: {id: track.id}}"> <i class="wrench icon"></i> <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> @@ -124,11 +133,13 @@ import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon" import Modal from '@/components/semantic/Modal' import EmbedWizard from "@/components/audio/EmbedWizard" import TagsList from "@/components/tags/List" +import ReportMixin from '@/components/mixins/Report' const FETCH_URL = "tracks/" export default { props: ["id"], + mixins: [ReportMixin], components: { PlayButton, TrackPlaylistIcon, diff --git a/front/src/components/mixins/Report.vue b/front/src/components/mixins/Report.vue new file mode 100644 index 0000000000000000000000000000000000000000..94e72a96c4238fc79507ccdecde64f378e68d423 --- /dev/null +++ b/front/src/components/mixins/Report.vue @@ -0,0 +1,75 @@ +<script> +export default { + methods: { + getReportableObjs ({track, album, artist, playlist, account}) { + let reportableObjs = [] + if (account) { + let accountLabel = this.$pgettext('*/Moderation/*/Verb', "Report @%{ username }…") + reportableObjs.push({ + label: this.$gettextInterpolate(accountLabel, {username: account.preferred_username}), + target: { + type: 'account', + full_username: account.full_username, + label: account.full_username, + typeLabel: this.$pgettext("*/*/*", 'Account'), + } + }) + if (track) { + album = track.album + artist = track.artist + } + } + if (track) { + reportableObjs.push({ + label: this.$pgettext('*/Moderation/*/Verb', "Report this track…"), + target: { + type: 'track', + id: track.id, + label: track.title, + typeLabel: this.$pgettext("*/*/*", 'Track'), + } + }) + album = track.album + artist = track.artist + } + if (album) { + reportableObjs.push({ + label: this.$pgettext('*/Moderation/*/Verb', "Report this album…"), + target: { + type: 'album', + id: album.id, + label: album.title, + typeLabel: this.$pgettext("*/*/*", 'Album'), + } + }) + if (!artist) { + artist = album.artist + } + } + if (artist) { + reportableObjs.push({ + label: this.$pgettext('*/Moderation/*/Verb', "Report this artist…"), + target: { + type: 'artist', + id: artist.id, + label: artist.name, + typeLabel: this.$pgettext("*/*/*", 'Artist'), + } + }) + } + if (this.playlist) { + reportableObjs.push({ + label: this.$pgettext('*/Moderation/*/Verb', "Report this playlist…"), + target: { + type: 'playlist', + id: this.playlist.id, + label: this.playlist.name, + typeLabel: this.$pgettext("*/*/*", 'Playlist'), + } + }) + } + return reportableObjs + }, + } +} +</script> diff --git a/front/src/components/moderation/ReportCategoryDropdown.vue b/front/src/components/moderation/ReportCategoryDropdown.vue index 473fe0adbcc05c1bf9270af5fbd45400deffc669..617f89b182a293aca78e96a270b0bbd0032530e1 100644 --- a/front/src/components/moderation/ReportCategoryDropdown.vue +++ b/front/src/components/moderation/ReportCategoryDropdown.vue @@ -1,7 +1,8 @@ <template> <div> <label v-if="label"><translate translate-context="*/*/*">Category</translate></label> - <select class="ui dropdown" :value="value" @change="$emit('input', $event.target.value)"> + <select class="ui dropdown" :value="value" @change="$emit('input', $event.target.value)" :required="required"> + <option v-if="empty" disabled value=''></option> <option :value="option.value" v-for="option in allCategories">{{ option.label }}</option> </select> <slot></slot> @@ -13,7 +14,14 @@ import TranslationsMixin from '@/components/mixins/Translations' import lodash from '@/lodash' export default { mixins: [TranslationsMixin], - props: ['value', 'all', 'label'], + props: { + value: {}, + all: {}, + label: {}, + empty: {}, + required: {}, + restrictTo: {default: () => { return [] }} + }, computed: { allCategories () { let c = [] @@ -25,11 +33,17 @@ export default { }, ) } + let choices + if (this.restrictTo.length > 0) { + choices = this.restrictTo + } else { + choices = lodash.keys(this.sharedLabels.fields.report_type.choices) + } return c.concat( - lodash.keys(this.sharedLabels.fields.report_type.choices).sort().map((v) => { + choices.sort().map((v) => { return { value: v, - label: this.sharedLabels.fields.report_type.choices[v] + label: this.sharedLabels.fields.report_type.choices[v] || v } }) ) diff --git a/front/src/components/moderation/ReportModal.vue b/front/src/components/moderation/ReportModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..e8a30c512ef7d97a7f38fd63d22f86cf02c88e89 --- /dev/null +++ b/front/src/components/moderation/ReportModal.vue @@ -0,0 +1,169 @@ +<template> + <modal @update:show="update" :show="$store.state.moderation.showReportModal"> + <h2 class="ui header" v-if="target"> + <translate translate-context="Popup/Moderation/Title/Verb">Do you want to report this object?</translate> + <div class="ui sub header"> + {{ target.typeLabel }} - {{ target.label }} + </div> + </h2> + <div class="scrolling content"> + <div class="description"> + <div v-if="errors.length > 0" class="ui negative message"> + <div class="header"><translate translate-context="Popup/Moderation/Error message">Error while submitting report</translate></div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + </div> + <p> + <translate translate-context="*/Moderation/Popup,Paragraph">Use this form to submit a report to our moderation team.</translate> + </p> + <form v-if="canSubmit" id="report-form" class="ui form" @submit.prevent="submit"> + <div v-if="!$store.state.auth.authenticated" class="ui inline required field"> + <label for="report-submitter-email"> + <translate translate-context="Content/*/*/Noun">Email</translate> + </label> + <input type="email" v-model="submitterEmail" name="report-submitter-email" id="report-submitter-email" required> + </div> + <report-category-dropdown + class="ui inline required field" + v-model="category" + :required="true" + :empty="true" + :restrict-to="allowedCategories" + :label="true"></report-category-dropdown> + <div class="ui field"> + <label for="report-summary"> + <translate translate-context="*/*/Field.Label/Noun">Message</translate> + </label> + <p> + <translate translate-context="*/*/Field,Help">Use this field to provide additional context to the moderator that will handle your report.</translate> + </p> + <textarea name="report-summary" id="report-summary" rows="8" v-model="summary"></textarea> + </div> + </form> + <div v-else-if="isLoadingReportTypes" class="ui inline active loader"> + + </div> + <div v-else class="ui warning message"> + <div class="header"> + <translate translate-context="Popup/Moderation/Error message">Anonymous reports are disabled, please sign-in to submit a report.</translate> + </div> + </div> + </div> + <div class="actions"> + <div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div> + <button + v-if="canSubmit" + :class="['ui', 'green', {loading: isLoading}, 'button']" + type="submit" form="report-form"> + <translate translate-context="Popup/*/Button.Label">Submit report</translate> + </button> + </div> + </modal> +</template> + +<script> +import _ from '@/lodash' +import axios from 'axios' +import {mapState} from 'vuex' + +import logger from '@/logging' +import Modal from '@/components/semantic/Modal' +import ReportCategoryDropdown from '@/components/moderation/ReportCategoryDropdown' + +export default { + components: { + Modal, + ReportCategoryDropdown, + }, + data () { + return { + formKey: String(new Date()), + errors: [], + isLoading: false, + isLoadingReportTypes: false, + summary: '', + submitterEmail: '', + category: null, + reportTypes: [], + } + }, + computed: { + ...mapState({ + target: state => state.moderation.reportModalTarget, + }), + allowedCategories () { + if (this.$store.state.auth.authenticated) { + return [] + } + return this.reportTypes.filter((t) => { + return t.anonymous === true + }).map((c) => { + return c.type + }) + + }, + canSubmit () { + if (this.$store.state.auth.authenticated) { + return true + } + + return this.allowedCategories.length > 0 + } + }, + methods: { + update (v) { + this.$store.commit('moderation/showReportModal', v) + this.errors = [] + }, + submit () { + let self = this + self.isLoading = true + let payload = { + target: this.target, + summary: this.summary, + type: this.category, + } + if (!this.$store.state.auth.authenticated) { + payload.submitter_email = this.submitterEmail + } + return axios.post('moderation/reports/', payload).then(response => { + self.update(false) + self.isLoading = false + let msg = this.$pgettext('*/Moderation/Message', 'Report successfully submitted, thank you') + self.$store.commit('moderation/contentFilter', response.data) + self.$store.commit('ui/addMessage', { + content: msg, + date: new Date() + }) + self.summary = '' + self.category = '' + }, error => { + self.errors = error.backendErrors + self.isLoading = false + }) + } + }, + watch: { + '$store.state.moderation.showReportModal': function (v) { + if (!v || this.$store.state.auth.authenticated) { + return + } + + let self = this + self.isLoadingReportTypes = true + axios.get('instance/nodeinfo/2.0/').then(response => { + self.isLoadingReportTypes = false + self.reportTypes = response.data.metadata.reportTypes || [] + }, error => { + self.isLoadingReportTypes = false + }) + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/components/playlists/Card.vue b/front/src/components/playlists/Card.vue index 6efc2350e00b990d0410c0e8c644340a5b7c3f4f..a8e5c149a5efb37aa8ba5c2ba76279ce444ad512 100644 --- a/front/src/components/playlists/Card.vue +++ b/front/src/components/playlists/Card.vue @@ -5,8 +5,18 @@ <div class="content"> <div class="header"> <div class="right floated"> - <play-button :is-playable="playlist.is_playable" :icon-only="true" class="ui inline" :button-classes="['ui', 'circular', 'large', {orange: playlist.tracks_count > 0}, 'icon', 'button', {disabled: playlist.tracks_count === 0}]" :playlist="playlist"></play-button> - <play-button :is-playable="playlist.is_playable" class="basic inline icon" :dropdown-only="true" :dropdown-icon-classes="['ellipsis', 'vertical', 'large', {disabled: playlist.tracks_count === 0}, 'grey']" :playlist="playlist"></play-button> + <play-button + :is-playable="playlist.is_playable" + :icon-only="true" class="ui inline" + :button-classes="['ui', 'circular', 'large', {orange: playlist.tracks_count > 0}, 'icon', 'button', {disabled: playlist.tracks_count === 0}]" + :playlist="playlist"></play-button> + <play-button + :is-playable="playlist.is_playable" + class="basic inline icon" + :dropdown-only="true" + :dropdown-icon-classes="['ellipsis', 'vertical', 'large', {disabled: playlist.tracks_count === 0}, 'grey']" + :account="playlist.actor" + :playlist="playlist"></play-button> </div> <router-link :title="playlist.name" class="discrete link" :to="{name: 'library.playlists.detail', params: {id: playlist.id }}"> {{ playlist.name | truncate(30) }} diff --git a/front/src/store/moderation.js b/front/src/store/moderation.js index 153f3cd5959d307d0edbf7760a4ff34d6a8eced3..16caf60aa4a5abca53b6fd06f4f4e7ff61f37c36 100644 --- a/front/src/store/moderation.js +++ b/front/src/store/moderation.js @@ -7,16 +7,24 @@ export default { state: { filters: [], showFilterModal: false, + showReportModal: false, lastUpdate: new Date(), filterModalTarget: { type: null, target: null, + }, + reportModalTarget: { + type: null, + target: null, } }, mutations: { filterModalTarget (state, value) { state.filterModalTarget = value }, + reportModalTarget (state, value) { + state.reportModalTarget = value + }, empty (state) { state.filters = [] }, @@ -35,10 +43,21 @@ export default { } } }, + showReportModal (state, value) { + state.showReportModal = value + if (!value) { + state.reportModalTarget = { + type: null, + target: null, + } + } + }, reset (state) { state.filters = [] state.filterModalTarget = null state.showFilterModal = false + state.showReportModal = false + state.reportModalTarget = {} }, deleteContentFilter (state, uuid) { state.filters = state.filters.filter((e) => { @@ -61,6 +80,10 @@ export default { commit('filterModalTarget', payload) commit('showFilterModal', true) }, + report ({commit}, payload) { + commit('reportModalTarget', payload) + commit('showReportModal', true) + }, fetchContentFilters ({dispatch, state, commit, rootState}, url) { let params = {} let promise