From ecd395d6b03895fc271e28179612e87f02974815 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Thu, 13 Sep 2018 15:18:23 +0000 Subject: [PATCH] Library follows and user notifications --- api/funkwhale_api/federation/activity.py | 85 +++++++++--- api/funkwhale_api/federation/admin.py | 62 ++++++--- .../federation/api_serializers.py | 72 +++++++++- api/funkwhale_api/federation/api_urls.py | 1 + api/funkwhale_api/federation/api_views.py | 67 +++++++++- api/funkwhale_api/federation/filters.py | 16 ++- .../migrations/0011_auto_20180910_1902.py | 61 +++++++++ api/funkwhale_api/federation/models.py | 34 ++++- api/funkwhale_api/federation/routes.py | 26 +++- api/funkwhale_api/federation/serializers.py | 1 + api/funkwhale_api/federation/tasks.py | 8 +- api/funkwhale_api/federation/views.py | 5 +- api/funkwhale_api/music/views.py | 20 +++ api/funkwhale_api/users/models.py | 2 +- api/tests/conftest.py | 38 ++++++ api/tests/federation/test_activity.py | 79 ++++++++--- api/tests/federation/test_actors.py | 107 --------------- api/tests/federation/test_api_filters.py | 9 ++ api/tests/federation/test_api_serializers.py | 9 ++ api/tests/federation/test_api_views.py | 81 +++++++++++ api/tests/federation/test_routes.py | 34 +++-- api/tests/federation/test_tasks.py | 4 +- api/tests/federation/test_views.py | 4 +- api/tests/music/test_views.py | 20 +++ api/tests/users/test_models.py | 5 +- front/src/App.vue | 67 +++++++++- front/src/components/Sidebar.vue | 47 ++----- front/src/components/common/ActorAvatar.vue | 21 +++ front/src/components/common/ActorLink.vue | 21 +-- front/src/components/globals.js | 4 + front/src/components/library/FileUpload.vue | 90 +++++++------ .../notifications/NotificationRow.vue | 126 ++++++++++++++++++ front/src/router/index.js | 6 + front/src/store/auth.js | 1 + front/src/store/ui.js | 37 +++-- front/src/views/Notifications.vue | 125 +++++++++++++++++ front/src/views/content/libraries/Detail.vue | 90 +++++++++++-- .../views/content/libraries/DetailArea.vue | 5 +- front/src/views/content/libraries/Upload.vue | 18 --- front/src/views/content/remote/Card.vue | 5 + front/src/views/content/remote/Home.vue | 25 +++- 41 files changed, 1191 insertions(+), 347 deletions(-) create mode 100644 api/funkwhale_api/federation/migrations/0011_auto_20180910_1902.py create mode 100644 api/tests/federation/test_api_filters.py create mode 100644 front/src/components/common/ActorAvatar.vue create mode 100644 front/src/components/notifications/NotificationRow.vue create mode 100644 front/src/views/Notifications.vue diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py index 2e74078008..e733219437 100644 --- a/api/funkwhale_api/federation/activity.py +++ b/api/funkwhale_api/federation/activity.py @@ -1,7 +1,13 @@ import uuid +import logging +from django.db import transaction, IntegrityError +from django.utils import timezone + +from funkwhale_api.common import channels from funkwhale_api.common import utils as funkwhale_utils +logger = logging.getLogger(__name__) PUBLIC_ADDRESS = "https://www.w3.org/ns/activitystreams#Public" ACTIVITY_TYPES = [ @@ -54,19 +60,10 @@ OBJECT_TYPES = [ ] + ACTIVITY_TYPES -def deliver(activity, on_behalf_of, to=[]): - from . import tasks - - return tasks.send.delay(activity=activity, actor_id=on_behalf_of.pk, to=to) - - -def accept_follow(follow): - from . import serializers - - serializer = serializers.AcceptFollowSerializer(follow) - return deliver(serializer.data, to=[follow.actor.fid], on_behalf_of=follow.target) +BROADCAST_TO_USER_ACTIVITIES = ["Follow", "Accept"] +@transaction.atomic def receive(activity, on_behalf_of): from . import models from . import serializers @@ -78,7 +75,14 @@ def receive(activity, on_behalf_of): data=activity, context={"actor": on_behalf_of, "local_recipients": True} ) serializer.is_valid(raise_exception=True) - copy = serializer.save() + try: + copy = serializer.save() + except IntegrityError: + logger.warning( + "[federation] Discarding already elivered activity %s", + serializer.validated_data.get("id"), + ) + return # we create inbox items for further delivery items = [ models.InboxItem(activity=copy, actor=r, type="to") @@ -93,7 +97,7 @@ def receive(activity, on_behalf_of): models.InboxItem.objects.bulk_create(items) # at this point, we have the activity in database. Even if we crash, it's # okay, as we can retry later - tasks.dispatch_inbox.delay(activity_id=copy.pk) + funkwhale_utils.on_commit(tasks.dispatch_inbox.delay, activity_id=copy.pk) return copy @@ -113,17 +117,64 @@ class Router: class InboxRouter(Router): + @transaction.atomic def dispatch(self, payload, context): """ Receives an Activity payload and some context and trigger our business logic """ + from . import api_serializers + from . import models + for route, handler in self.routes: if match_route(route, payload): - return handler(payload, context=context) + r = handler(payload, context=context) + activity_obj = context.get("activity") + if activity_obj and r: + # handler returned additional data we can use + # to update the activity target + for key, value in r.items(): + setattr(activity_obj, key, value) + + update_fields = [] + for k in r.keys(): + if k in ["object", "target", "related_object"]: + update_fields += [ + "{}_id".format(k), + "{}_content_type".format(k), + ] + else: + update_fields.append(k) + activity_obj.save(update_fields=update_fields) + + if payload["type"] not in BROADCAST_TO_USER_ACTIVITIES: + return + + inbox_items = context.get( + "inbox_items", models.InboxItem.objects.none() + ) + for ii in inbox_items: + user = ii.actor.get_user() + if not user: + continue + group = "user.{}.inbox".format(user.pk) + channels.group_send( + group, + { + "type": "event.send", + "text": "", + "data": { + "type": "inbox.item_added", + "item": api_serializers.InboxItemSerializer(ii).data, + }, + }, + ) + inbox_items.update(is_delivered=True, last_delivery_date=timezone.now()) + return class OutboxRouter(Router): + @transaction.atomic def dispatch(self, routing, context): """ Receives a routing payload and some business objects in the context @@ -140,12 +191,11 @@ class OutboxRouter(Router): # a route can yield zero, one or more activity payloads if e: activities_data.append(e) - inbox_items_by_activity_uuid = {} prepared_activities = [] for activity_data in activities_data: - to = activity_data.pop("to", []) - cc = activity_data.pop("cc", []) + to = activity_data["payload"].pop("to", []) + cc = activity_data["payload"].pop("cc", []) a = models.Activity(**activity_data) a.uuid = uuid.uuid4() to_items, new_to = prepare_inbox_items(to, "to") @@ -160,7 +210,6 @@ class OutboxRouter(Router): prepared_activities.append(a) activities = models.Activity.objects.bulk_create(prepared_activities) - activities = [a for a in activities if a] final_inbox_items = [] for a in activities: diff --git a/api/funkwhale_api/federation/admin.py b/api/funkwhale_api/federation/admin.py index 4d8af0bcfc..81bf653ed3 100644 --- a/api/funkwhale_api/federation/admin.py +++ b/api/funkwhale_api/federation/admin.py @@ -1,6 +1,38 @@ from django.contrib import admin from . import models +from . import tasks + + +def redeliver_inbox_items(modeladmin, request, queryset): + for id in set( + queryset.filter(activity__actor__user__isnull=False).values_list( + "activity", flat=True + ) + ): + tasks.dispatch_outbox.delay(activity_id=id) + + +redeliver_inbox_items.short_description = "Redeliver" + + +def redeliver_activities(modeladmin, request, queryset): + for id in set( + queryset.filter(actor__user__isnull=False).values_list("id", flat=True) + ): + tasks.dispatch_outbox.delay(activity_id=id) + + +redeliver_activities.short_description = "Redeliver" + + +@admin.register(models.Activity) +class ActivityAdmin(admin.ModelAdmin): + list_display = ["type", "fid", "url", "actor", "creation_date"] + search_fields = ["payload", "fid", "url", "actor__domain"] + list_filter = ["type", "actor__domain"] + actions = [redeliver_activities] + list_select_related = True @admin.register(models.Actor) @@ -25,24 +57,24 @@ class FollowAdmin(admin.ModelAdmin): list_select_related = True -@admin.register(models.Library) -class LibraryAdmin(admin.ModelAdmin): - list_display = ["actor", "url", "creation_date", "fetched_date", "tracks_count"] - search_fields = ["actor__fid", "url"] - list_filter = ["federation_enabled", "download_files", "autoimport"] +@admin.register(models.LibraryFollow) +class LibraryFollowAdmin(admin.ModelAdmin): + list_display = ["actor", "target", "approved", "creation_date"] + list_filter = ["approved"] + search_fields = ["actor__fid", "target__fid"] list_select_related = True -@admin.register(models.LibraryTrack) -class LibraryTrackAdmin(admin.ModelAdmin): +@admin.register(models.InboxItem) +class InboxItemAdmin(admin.ModelAdmin): list_display = [ - "title", - "artist_name", - "album_title", - "url", - "library", - "creation_date", - "published_date", + "actor", + "activity", + "type", + "last_delivery_date", + "delivery_attempts", ] - search_fields = ["library__url", "url", "artist_name", "title", "album_title"] + list_filter = ["type"] + search_fields = ["actor__fid", "activity__fid"] list_select_related = True + actions = [redeliver_inbox_items] diff --git a/api/funkwhale_api/federation/api_serializers.py b/api/funkwhale_api/federation/api_serializers.py index e8c30db0c6..da6622f23f 100644 --- a/api/funkwhale_api/federation/api_serializers.py +++ b/api/funkwhale_api/federation/api_serializers.py @@ -3,8 +3,9 @@ from rest_framework import serializers from funkwhale_api.common import serializers as common_serializers from funkwhale_api.music import models as music_models -from . import serializers as federation_serializers +from . import filters from . import models +from . import serializers as federation_serializers class NestedLibraryFollowSerializer(serializers.ModelSerializer): @@ -44,14 +45,79 @@ class LibrarySerializer(serializers.ModelSerializer): class LibraryFollowSerializer(serializers.ModelSerializer): target = common_serializers.RelatedField("uuid", LibrarySerializer(), required=True) + actor = serializers.SerializerMethodField() class Meta: model = models.LibraryFollow - fields = ["creation_date", "uuid", "target", "approved"] - read_only_fields = ["uuid", "approved", "creation_date"] + fields = ["creation_date", "actor", "uuid", "target", "approved"] + read_only_fields = ["uuid", "actor", "approved", "creation_date"] def validate_target(self, v): actor = self.context["actor"] if v.received_follows.filter(actor=actor).exists(): raise serializers.ValidationError("You are already following this library") return v + + def get_actor(self, o): + return federation_serializers.APIActorSerializer(o.actor).data + + +def serialize_generic_relation(activity, obj): + data = {"uuid": obj.uuid, "type": obj._meta.label} + if data["type"] == "music.Library": + data["name"] = obj.name + if data["type"] == "federation.LibraryFollow": + data["approved"] = obj.approved + + return data + + +class ActivitySerializer(serializers.ModelSerializer): + actor = federation_serializers.APIActorSerializer() + object = serializers.SerializerMethodField() + target = serializers.SerializerMethodField() + related_object = serializers.SerializerMethodField() + + class Meta: + model = models.Activity + fields = [ + "uuid", + "fid", + "actor", + "payload", + "object", + "target", + "related_object", + "actor", + "creation_date", + "type", + ] + + def get_object(self, o): + if o.object: + return serialize_generic_relation(o, o.object) + + def get_related_object(self, o): + if o.related_object: + return serialize_generic_relation(o, o.related_object) + + def get_target(self, o): + if o.target: + return serialize_generic_relation(o, o.target) + + +class InboxItemSerializer(serializers.ModelSerializer): + activity = ActivitySerializer() + + class Meta: + model = models.InboxItem + fields = ["id", "type", "activity", "is_read"] + read_only_fields = ["id", "type", "activity"] + + +class InboxItemActionSerializer(common_serializers.ActionSerializer): + actions = [common_serializers.Action("read", allow_all=True)] + filterset_class = filters.InboxItemFilter + + def handle_read(self, objects): + return objects.update(is_read=True) diff --git a/api/funkwhale_api/federation/api_urls.py b/api/funkwhale_api/federation/api_urls.py index 831bc66304..e1e451bff9 100644 --- a/api/funkwhale_api/federation/api_urls.py +++ b/api/funkwhale_api/federation/api_urls.py @@ -4,6 +4,7 @@ from . import api_views router = routers.SimpleRouter() router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows") +router.register(r"inbox", api_views.InboxItemViewSet, "inbox") router.register(r"libraries", api_views.LibraryViewSet, "libraries") urlpatterns = router.urls diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py index 75ca896093..079a4f3b8d 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -1,5 +1,6 @@ import requests.exceptions +from django.db import transaction from django.db.models import Count from rest_framework import decorators @@ -10,6 +11,7 @@ from rest_framework import viewsets from funkwhale_api.music import models as music_models +from . import activity from . import api_serializers from . import filters from . import models @@ -18,6 +20,13 @@ from . import serializers from . import utils +@transaction.atomic +def update_follow(follow, approved): + follow.approved = approved + follow.save(update_fields=["approved"]) + routes.outbox.dispatch({"type": "Accept"}, context={"follow": follow}) + + class LibraryFollowViewSet( mixins.CreateModelMixin, mixins.ListModelMixin, @@ -48,6 +57,29 @@ class LibraryFollowViewSet( context["actor"] = self.request.user.actor return context + @decorators.detail_route(methods=["post"]) + def accept(self, request, *args, **kwargs): + try: + follow = self.queryset.get( + target__actor=self.request.user.actor, uuid=kwargs["uuid"] + ) + except models.LibraryFollow.DoesNotExist: + return response.Response({}, status=404) + update_follow(follow, approved=True) + return response.Response(status=204) + + @decorators.detail_route(methods=["post"]) + def reject(self, request, *args, **kwargs): + try: + follow = self.queryset.get( + target__actor=self.request.user.actor, uuid=kwargs["uuid"] + ) + except models.LibraryFollow.DoesNotExist: + return response.Response({}, status=404) + + update_follow(follow, approved=False) + return response.Response(status=204) + class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): lookup_field = "uuid" @@ -59,8 +91,6 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): ) serializer_class = api_serializers.LibrarySerializer permission_classes = [permissions.IsAuthenticated] - filter_class = filters.LibraryFollowFilter - ordering_fields = ("creation_date",) def get_queryset(self): qs = super().get_queryset() @@ -90,3 +120,36 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): ) serializer = self.serializer_class(library) return response.Response({"count": 1, "results": [serializer.data]}) + + +class InboxItemViewSet( + mixins.UpdateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + + queryset = ( + models.InboxItem.objects.select_related("activity__actor") + .prefetch_related("activity__object", "activity__target") + .filter(activity__type__in=activity.BROADCAST_TO_USER_ACTIVITIES, type="to") + .order_by("-activity__creation_date") + ) + serializer_class = api_serializers.InboxItemSerializer + permission_classes = [permissions.IsAuthenticated] + filter_class = filters.InboxItemFilter + ordering_fields = ("activity__creation_date",) + + def get_queryset(self): + qs = super().get_queryset() + return qs.filter(actor=self.request.user.actor) + + @decorators.list_route(methods=["post"]) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = api_serializers.InboxItemActionSerializer( + request.data, queryset=queryset + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) diff --git a/api/funkwhale_api/federation/filters.py b/api/funkwhale_api/federation/filters.py index 658bd411cb..3a8b76ceee 100644 --- a/api/funkwhale_api/federation/filters.py +++ b/api/funkwhale_api/federation/filters.py @@ -1,4 +1,4 @@ -import django_filters +import django_filters.widgets from funkwhale_api.common import fields @@ -32,3 +32,17 @@ class LibraryFollowFilter(django_filters.FilterSet): class Meta: model = models.LibraryFollow fields = ["approved"] + + +class InboxItemFilter(django_filters.FilterSet): + is_read = django_filters.BooleanFilter( + "is_read", widget=django_filters.widgets.BooleanWidget() + ) + before = django_filters.NumberFilter(method="filter_before") + + class Meta: + model = models.InboxItem + fields = ["is_read", "activity__type", "activity__actor"] + + def filter_before(self, queryset, field_name, value): + return queryset.filter(pk__lte=value) diff --git a/api/funkwhale_api/federation/migrations/0011_auto_20180910_1902.py b/api/funkwhale_api/federation/migrations/0011_auto_20180910_1902.py new file mode 100644 index 0000000000..feeeaff865 --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0011_auto_20180910_1902.py @@ -0,0 +1,61 @@ +# Generated by Django 2.0.8 on 2018-09-10 19:02 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('federation', '0010_auto_20180904_2011'), + ] + + operations = [ + migrations.AddField( + model_name='activity', + name='object_content_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objecting_activities', to='contenttypes.ContentType'), + ), + migrations.AddField( + model_name='activity', + name='object_id', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='activity', + name='related_object_content_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_objecting_activities', to='contenttypes.ContentType'), + ), + migrations.AddField( + model_name='activity', + name='related_object_id', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='activity', + name='target_content_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='targeting_activities', to='contenttypes.ContentType'), + ), + migrations.AddField( + model_name='activity', + name='target_id', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='activity', + name='type', + field=models.CharField(db_index=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='inboxitem', + name='is_read', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='activity', + name='creation_date', + field=models.DateTimeField(db_index=True, default=django.utils.timezone.now), + ), + ] diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 8dbf639161..32c4cae114 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -3,6 +3,8 @@ import uuid from django.conf import settings from django.contrib.postgres.fields import JSONField +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.core.serializers.json import DjangoJSONEncoder from django.db import models @@ -173,6 +175,7 @@ class InboxItem(models.Model): type = models.CharField(max_length=10, choices=[("to", "to"), ("cc", "cc")]) last_delivery_date = models.DateTimeField(null=True, blank=True) delivery_attempts = models.PositiveIntegerField(default=0) + is_read = models.BooleanField(default=False) objects = InboxItemQuerySet.as_manager() @@ -188,7 +191,36 @@ class Activity(models.Model): fid = models.URLField(unique=True, max_length=500, null=True, blank=True) url = models.URLField(max_length=500, null=True, blank=True) payload = JSONField(default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder) - creation_date = models.DateTimeField(default=timezone.now) + creation_date = models.DateTimeField(default=timezone.now, db_index=True) + type = models.CharField(db_index=True, null=True, max_length=100) + + # generic relations + object_id = models.IntegerField(null=True) + object_content_type = models.ForeignKey( + ContentType, + null=True, + on_delete=models.SET_NULL, + related_name="objecting_activities", + ) + object = GenericForeignKey("object_content_type", "object_id") + target_id = models.IntegerField(null=True) + target_content_type = models.ForeignKey( + ContentType, + null=True, + on_delete=models.SET_NULL, + related_name="targeting_activities", + ) + target = GenericForeignKey("target_content_type", "target_id") + related_object_id = models.IntegerField(null=True) + related_object_content_type = models.ForeignKey( + ContentType, + null=True, + on_delete=models.SET_NULL, + related_name="related_objecting_activities", + ) + related_object = GenericForeignKey( + "related_object_content_type", "related_object_id" + ) class AbstractFollow(models.Model): diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py index d247b3d993..41f13801fe 100644 --- a/api/funkwhale_api/federation/routes.py +++ b/api/funkwhale_api/federation/routes.py @@ -33,10 +33,10 @@ def inbox_follow(payload, context): autoapprove = serializer.validated_data["object"].should_autoapprove_follow( context["actor"] ) - follow = serializer.save(approved=autoapprove) - - if autoapprove: - activity.accept_follow(follow) + follow = serializer.save(approved=True if autoapprove else None) + if follow.approved: + outbox.dispatch({"type": "Accept"}, context={"follow": follow}) + return {"object": follow.target, "related_object": follow} @inbox.register({"type": "Accept"}) @@ -54,6 +54,8 @@ def inbox_accept(payload, context): return serializer.save() + obj = serializer.validated_data["follow"] + return {"object": obj, "related_object": obj.target} @outbox.register({"type": "Accept"}) @@ -64,7 +66,13 @@ def outbox_accept(context): else: actor = follow.target payload = serializers.AcceptFollowSerializer(follow, context={"actor": actor}).data - yield {"actor": actor, "payload": with_recipients(payload, to=[follow.actor])} + yield { + "actor": actor, + "type": "Accept", + "payload": with_recipients(payload, to=[follow.actor]), + "object": follow, + "related_object": follow.target, + } @outbox.register({"type": "Follow"}) @@ -75,4 +83,10 @@ def outbox_follow(context): else: target = follow.target payload = serializers.FollowSerializer(follow, context={"actor": follow.actor}).data - yield {"actor": follow.actor, "payload": with_recipients(payload, to=[target])} + yield { + "type": "Follow", + "actor": follow.actor, + "payload": with_recipients(payload, to=[target]), + "object": follow.target, + "related_object": follow, + } diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 30b5dad4d5..91df2bbbbf 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -155,6 +155,7 @@ class BaseActivitySerializer(serializers.Serializer): fid=validated_data.get("id"), actor=validated_data["actor"], payload=self.initial_data, + type=validated_data["type"], ) def validate(self, data): diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py index 2d7e37a170..d5876174da 100644 --- a/api/funkwhale_api/federation/tasks.py +++ b/api/funkwhale_api/federation/tasks.py @@ -74,8 +74,14 @@ def dispatch_inbox(activity): routes.inbox.dispatch( activity.payload, context={ + "activity": activity, "actor": activity.actor, - "inbox_items": list(activity.inbox_items.local().select_related()), + "inbox_items": ( + activity.inbox_items.local() + .select_related() + .select_related("actor__user") + .prefetch_related("activity__object", "activity__target") + ), }, ) except Exception: diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 30fdb9a9f2..ddc6a2fa54 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -37,15 +37,12 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV @detail_route(methods=["get", "post"]) def inbox(self, request, *args, **kwargs): - actor = self.get_object() if request.method.lower() == "post" and request.actor is None: raise exceptions.AuthenticationFailed( "You need a valid signature to send an activity" ) if request.method.lower() == "post": - activity.receive( - activity=request.data, on_behalf_of=request.actor, recipient=actor - ) + activity.receive(activity=request.data, on_behalf_of=request.actor) return response.Response({}, status=200) @detail_route(methods=["get", "post"]) diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index e8ddd00a74..30ead2398a 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -18,6 +18,7 @@ from taggit.models import Tag from funkwhale_api.common import utils as common_utils from funkwhale_api.common import permissions as common_permissions from funkwhale_api.federation.authentication import SignatureAuthentication +from funkwhale_api.federation import api_serializers as federation_api_serializers from . import filters, models, serializers, tasks, utils @@ -94,6 +95,25 @@ class LibraryViewSet( def perform_create(self, serializer): serializer.save(actor=self.request.user.actor) + @detail_route(methods=["get"]) + @transaction.non_atomic_requests + def follows(self, request, *args, **kwargs): + library = self.get_object() + queryset = ( + library.received_follows.filter(target__actor=self.request.user.actor) + .select_related("actor", "target__actor") + .order_by("-creation_date") + ) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = federation_api_serializers.LibraryFollowSerializer( + page, many=True + ) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): """ diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index d2e1ac65ca..89abefb484 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -206,7 +206,7 @@ class User(AbstractUser): } def get_channels_groups(self): - groups = ["imports"] + groups = ["imports", "inbox"] return ["user.{}.{}".format(self.pk, g) for g in groups] diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 8ad554d81c..cf6a3082e4 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -15,6 +15,7 @@ from django.core.cache import cache as django_cache from django.core.files import uploadedfile from django.utils import timezone from django.test import client +from django.db.models import QuerySet from dynamic_preferences.registries import global_preferences_registry from rest_framework import fields as rest_fields from rest_framework.test import APIClient, APIRequestFactory @@ -23,6 +24,43 @@ from funkwhale_api.activity import record from funkwhale_api.users.permissions import HasUserPermission +@pytest.fixture +def queryset_equal_queries(): + """ + Unitting querysets is hard because we have to compare queries + by hand. Let's monkey patch querysets to do that for us. + """ + + def __eq__(self, other): + if isinstance(other, QuerySet): + return str(other.query) == str(self.query) + else: + return False + + setattr(QuerySet, "__eq__", __eq__) + yield __eq__ + delattr(QuerySet, "__eq__") + + +@pytest.fixture +def queryset_equal_list(): + """ + Unitting querysets is hard because we usually simply wants to ensure + a querysets contains the same objects as a list, let's monkey patch + querysets to to that for us. + """ + + def __eq__(self, other): + if isinstance(other, (list, tuple)): + return list(self) == list(other) + else: + return False + + setattr(QuerySet, "__eq__", __eq__) + yield __eq__ + delattr(QuerySet, "__eq__") + + @pytest.fixture(scope="session", autouse=True) def factories_autodiscover(): from django.apps import apps diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py index 7ca3371943..592de3b15d 100644 --- a/api/tests/federation/test_activity.py +++ b/api/tests/federation/test_activity.py @@ -1,23 +1,11 @@ import pytest -from funkwhale_api.federation import activity, serializers, tasks - - -def test_accept_follow(mocker, factories): - deliver = mocker.patch("funkwhale_api.federation.activity.deliver") - follow = factories["federation.Follow"](approved=None) - expected_accept = serializers.AcceptFollowSerializer(follow).data - activity.accept_follow(follow) - deliver.assert_called_once_with( - expected_accept, to=[follow.actor.fid], on_behalf_of=follow.target - ) +from funkwhale_api.federation import activity, api_serializers, serializers, tasks def test_receive_validates_basic_attributes_and_stores_activity(factories, now, mocker): - mocked_dispatch = mocker.patch( - "funkwhale_api.federation.tasks.dispatch_inbox.delay" - ) + mocked_dispatch = mocker.patch("funkwhale_api.common.utils.on_commit") local_actor = factories["users.User"]().create_actor() remote_actor = factories["federation.Actor"]() another_actor = factories["federation.Actor"]() @@ -36,7 +24,10 @@ def test_receive_validates_basic_attributes_and_stores_activity(factories, now, assert copy.creation_date >= now assert copy.actor == remote_actor assert copy.fid == a["id"] - mocked_dispatch.assert_called_once_with(activity_id=copy.pk) + assert copy.type == "Noop" + mocked_dispatch.assert_called_once_with( + tasks.dispatch_inbox.delay, activity_id=copy.pk + ) inbox_item = copy.inbox_items.get(actor__fid=local_actor.fid) assert inbox_item.is_delivered is False @@ -63,16 +54,62 @@ def test_receive_actor_mismatch(factories): activity.receive(activity=a, on_behalf_of=remote_actor) -def test_inbox_routing(mocker): +def test_inbox_routing(factories, mocker): + object = factories["music.Artist"]() + target = factories["music.Artist"]() router = activity.InboxRouter() + a = factories["federation.Activity"](type="Follow") + + handler_payload = {} + handler_context = {} + + def handler(payload, context): + handler_payload.update(payload) + handler_context.update(context) + return {"target": target, "object": object} - handler = mocker.stub(name="handler") router.connect({"type": "Follow"}, handler) good_message = {"type": "Follow"} - router.dispatch(good_message, context={}) + router.dispatch(good_message, context={"activity": a}) + + assert handler_payload == good_message + assert handler_context == {"activity": a} + + a.refresh_from_db() + + assert a.object == object + assert a.target == target - handler.assert_called_once_with(good_message, context={}) + +def test_inbox_routing_send_to_channel(factories, mocker): + group_send = mocker.patch("funkwhale_api.common.channels.group_send") + a = factories["federation.Activity"](type="Follow") + ii = factories["federation.InboxItem"](actor__local=True) + + router = activity.InboxRouter() + handler = mocker.stub() + router.connect({"type": "Follow"}, handler) + good_message = {"type": "Follow"} + router.dispatch( + good_message, context={"activity": a, "inbox_items": ii.__class__.objects.all()} + ) + + ii.refresh_from_db() + + assert ii.is_delivered is True + + group_send.assert_called_once_with( + "user.{}.inbox".format(ii.actor.user.pk), + { + "type": "event.send", + "text": "", + "data": { + "type": "inbox.item_added", + "item": api_serializers.InboxItemSerializer(ii).data, + }, + }, + ) @pytest.mark.parametrize( @@ -101,10 +138,10 @@ def test_outbox_router_dispatch(mocker, factories, now): "type": "Noop", "actor": actor.fid, "summary": context["summary"], + "to": [r1], + "cc": [r2, activity.PUBLIC_ADDRESS], }, "actor": actor, - "to": [r1], - "cc": [r2, activity.PUBLIC_ADDRESS], } router.connect({"type": "Noop"}, handler) diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py index f7d70f1771..71c1b047b1 100644 --- a/api/tests/federation/test_actors.py +++ b/api/tests/federation/test_actors.py @@ -1,6 +1,5 @@ import pytest from django.urls import reverse -from django.utils import timezone from rest_framework import exceptions from funkwhale_api.federation import actors, models, serializers, utils @@ -120,58 +119,6 @@ def test_test_post_outbox_validates_actor(nodb_factories): assert msg in exc_info.value -def test_test_post_inbox_handles_create_note(settings, mocker, factories): - deliver = mocker.patch("funkwhale_api.federation.activity.deliver") - actor = factories["federation.Actor"]() - now = timezone.now() - mocker.patch("django.utils.timezone.now", return_value=now) - data = { - "actor": actor.fid, - "type": "Create", - "id": "http://test.federation/activity", - "object": { - "type": "Note", - "id": "http://test.federation/object", - "content": "<p><a>@mention</a> /ping</p>", - }, - } - test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance() - expected_note = factories["federation.Note"]( - id="https://test.federation/activities/note/{}".format(now.timestamp()), - content="Pong!", - published=now.isoformat(), - inReplyTo=data["object"]["id"], - cc=[], - summary=None, - sensitive=False, - attributedTo=test_actor.fid, - attachment=[], - to=[actor.fid], - url="https://{}/activities/note/{}".format( - settings.FEDERATION_HOSTNAME, now.timestamp() - ), - tag=[{"href": actor.fid, "name": actor.full_username, "type": "Mention"}], - ) - expected_activity = { - "@context": serializers.AP_CONTEXT, - "actor": test_actor.fid, - "id": "https://{}/activities/note/{}/activity".format( - settings.FEDERATION_HOSTNAME, now.timestamp() - ), - "to": actor.fid, - "type": "Create", - "published": now.isoformat(), - "object": expected_note, - "cc": [], - } - actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor) - deliver.assert_called_once_with( - expected_activity, - to=[actor.fid], - on_behalf_of=actors.SYSTEM_ACTORS["test"].get_actor_instance(), - ) - - def test_getting_actor_instance_persists_in_db(db): test = actors.SYSTEM_ACTORS["test"].get_actor_instance() from_db = models.Actor.objects.get(fid=test.fid) @@ -220,57 +167,3 @@ def test_system_actor_handle(mocker, nodb_factories): assert serializer.is_valid() actors.SYSTEM_ACTORS["test"].handle(activity, actor) handler.assert_called_once_with(activity, actor) - - -def test_test_actor_handles_follow(settings, mocker, factories): - deliver = mocker.patch("funkwhale_api.federation.activity.deliver") - actor = factories["federation.Actor"]() - accept_follow = mocker.patch("funkwhale_api.federation.activity.accept_follow") - test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance() - data = { - "actor": actor.fid, - "type": "Follow", - "id": "http://test.federation/user#follows/267", - "object": test_actor.fid, - } - actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor) - follow = models.Follow.objects.get(target=test_actor, approved=True) - follow_back = models.Follow.objects.get(actor=test_actor, approved=None) - accept_follow.assert_called_once_with(follow) - deliver.assert_called_once_with( - serializers.FollowSerializer(follow_back).data, - on_behalf_of=test_actor, - to=[actor.fid], - ) - - -def test_test_actor_handles_undo_follow(settings, mocker, factories): - deliver = mocker.patch("funkwhale_api.federation.activity.deliver") - test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance() - follow = factories["federation.Follow"](target=test_actor) - reverse_follow = factories["federation.Follow"]( - actor=test_actor, target=follow.actor - ) - follow_serializer = serializers.FollowSerializer(follow) - reverse_follow_serializer = serializers.FollowSerializer(reverse_follow) - undo = { - "@context": serializers.AP_CONTEXT, - "type": "Undo", - "id": follow_serializer.data["id"] + "/undo", - "actor": follow.actor.fid, - "object": follow_serializer.data, - } - expected_undo = { - "@context": serializers.AP_CONTEXT, - "type": "Undo", - "id": reverse_follow_serializer.data["id"] + "/undo", - "actor": reverse_follow.actor.fid, - "object": reverse_follow_serializer.data, - } - - actors.SYSTEM_ACTORS["test"].post_inbox(undo, actor=follow.actor) - deliver.assert_called_once_with( - expected_undo, to=[follow.actor.fid], on_behalf_of=test_actor - ) - - assert models.Follow.objects.count() == 0 diff --git a/api/tests/federation/test_api_filters.py b/api/tests/federation/test_api_filters.py new file mode 100644 index 0000000000..c6e70b6178 --- /dev/null +++ b/api/tests/federation/test_api_filters.py @@ -0,0 +1,9 @@ +from funkwhale_api.federation import filters +from funkwhale_api.federation import models + + +def test_inbox_item_filter_before(factories): + expected = models.InboxItem.objects.filter(pk__lte=12) + f = filters.InboxItemFilter({"before": 12}, queryset=models.InboxItem.objects.all()) + + assert str(f.qs.query) == str(expected.query) diff --git a/api/tests/federation/test_api_serializers.py b/api/tests/federation/test_api_serializers.py index f3acc87310..32cbab523d 100644 --- a/api/tests/federation/test_api_serializers.py +++ b/api/tests/federation/test_api_serializers.py @@ -51,3 +51,12 @@ def test_library_serializer_validates_existing_follow(factories): assert serializer.is_valid() is False assert "target" in serializer.errors + + +def test_manage_track_file_action_read(factories): + ii = factories["federation.InboxItem"]() + s = api_serializers.InboxItemActionSerializer(queryset=None) + + s.handle_read(ii.__class__.objects.all()) + + assert ii.__class__.objects.filter(is_read=False).count() == 0 diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py index 44c7a882da..d99b850032 100644 --- a/api/tests/federation/test_api_views.py +++ b/api/tests/federation/test_api_views.py @@ -1,3 +1,5 @@ +import pytest + from django.urls import reverse from funkwhale_api.federation import api_serializers @@ -49,3 +51,82 @@ def test_can_follow_library(factories, logged_in_api_client, mocker): assert follow.actor == actor dispatch.assert_called_once_with({"type": "Follow"}, context={"follow": follow}) + + +@pytest.mark.parametrize("action", ["accept", "reject"]) +def test_user_cannot_edit_someone_else_library_follow( + factories, logged_in_api_client, action +): + logged_in_api_client.user.create_actor() + follow = factories["federation.LibraryFollow"]() + url = reverse( + "api:v1:federation:library-follows-{}".format(action), + kwargs={"uuid": follow.uuid}, + ) + response = logged_in_api_client.post(url) + + assert response.status_code == 404 + + +@pytest.mark.parametrize("action,expected", [("accept", True), ("reject", False)]) +def test_user_can_accept_or_reject_own_follows( + factories, logged_in_api_client, action, expected, mocker +): + mocked_dispatch = mocker.patch( + "funkwhale_api.federation.activity.OutboxRouter.dispatch" + ) + actor = logged_in_api_client.user.create_actor() + follow = factories["federation.LibraryFollow"](target__actor=actor) + url = reverse( + "api:v1:federation:library-follows-{}".format(action), + kwargs={"uuid": follow.uuid}, + ) + response = logged_in_api_client.post(url) + + assert response.status_code == 204 + + follow.refresh_from_db() + + assert follow.approved is expected + + mocked_dispatch.assert_called_once_with( + {"type": "Accept"}, context={"follow": follow} + ) + + +def test_user_can_list_inbox_items(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + ii = factories["federation.InboxItem"]( + activity__type="Follow", actor=actor, type="to" + ) + + factories["federation.InboxItem"](activity__type="Follow", actor=actor, type="cc") + factories["federation.InboxItem"](activity__type="Follow", type="to") + + url = reverse("api:v1:federation:inbox-list") + + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == { + "count": 1, + "results": [api_serializers.InboxItemSerializer(ii).data], + "next": None, + "previous": None, + } + + +def test_user_can_update_read_status_of_inbox_item(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + ii = factories["federation.InboxItem"]( + activity__type="Follow", actor=actor, type="to" + ) + + url = reverse("api:v1:federation:inbox-detail", kwargs={"pk": ii.pk}) + + response = logged_in_api_client.patch(url, {"is_read": True}) + assert response.status_code == 200 + + ii.refresh_from_db() + + assert ii.is_read is True diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index e7ac88bba4..8e8a50b5fe 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -36,8 +36,8 @@ def test_outbox_routes(route, handler): def test_inbox_follow_library_autoapprove(factories, mocker): - mocked_accept_follow = mocker.patch( - "funkwhale_api.federation.activity.accept_follow" + mocked_outbox_dispatch = mocker.patch( + "funkwhale_api.federation.activity.OutboxRouter.dispatch" ) local_actor = factories["users.User"]().create_actor() @@ -52,23 +52,27 @@ def test_inbox_follow_library_autoapprove(factories, mocker): "object": library.fid, } - routes.inbox_follow( + result = routes.inbox_follow( payload, context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True}, ) - follow = library.received_follows.latest("id") + assert result["object"] == library + assert result["related_object"] == follow + assert follow.fid == payload["id"] assert follow.actor == remote_actor assert follow.approved is True - mocked_accept_follow.assert_called_once_with(follow) + mocked_outbox_dispatch.assert_called_once_with( + {"type": "Accept"}, context={"follow": follow} + ) def test_inbox_follow_library_manual_approve(factories, mocker): - mocked_accept_follow = mocker.patch( - "funkwhale_api.federation.activity.accept_follow" + mocked_outbox_dispatch = mocker.patch( + "funkwhale_api.federation.activity.OutboxRouter.dispatch" ) local_actor = factories["users.User"]().create_actor() @@ -83,18 +87,20 @@ def test_inbox_follow_library_manual_approve(factories, mocker): "object": library.fid, } - routes.inbox_follow( + result = routes.inbox_follow( payload, context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True}, ) - follow = library.received_follows.latest("id") + assert result["object"] == library + assert result["related_object"] == follow + assert follow.fid == payload["id"] assert follow.actor == remote_actor - assert follow.approved is False + assert follow.approved is None - mocked_accept_follow.assert_not_called() + mocked_outbox_dispatch.assert_not_called() def test_outbox_accept(factories, mocker): @@ -111,6 +117,7 @@ def test_outbox_accept(factories, mocker): assert activity["payload"] == expected assert activity["actor"] == follow.target.actor + assert activity["object"] == follow def test_inbox_accept(factories, mocker): @@ -125,10 +132,12 @@ def test_inbox_accept(factories, mocker): follow, context={"actor": remote_actor} ) ii = factories["federation.InboxItem"](actor=local_actor) - routes.inbox_accept( + result = routes.inbox_accept( serializer.data, context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True}, ) + assert result["object"] == follow + assert result["related_object"] == follow.target follow.refresh_from_db() @@ -145,3 +154,4 @@ def test_outbox_follow_library(factories, mocker): assert activity["payload"] == expected assert activity["actor"] == follow.actor + assert activity["object"] == follow.target diff --git a/api/tests/federation/test_tasks.py b/api/tests/federation/test_tasks.py index 216163277d..9bf25f6973 100644 --- a/api/tests/federation/test_tasks.py +++ b/api/tests/federation/test_tasks.py @@ -59,7 +59,7 @@ def test_clean_federation_music_cache_orphaned(settings, preferences, factories) assert os.path.exists(remove_path) is False -def test_handle_in(factories, mocker, now): +def test_handle_in(factories, mocker, now, queryset_equal_list): mocked_dispatch = mocker.patch("funkwhale_api.federation.routes.inbox.dispatch") r1 = factories["users.User"](with_actor=True).actor @@ -70,7 +70,7 @@ def test_handle_in(factories, mocker, now): tasks.dispatch_inbox(activity_id=a.pk) mocked_dispatch.assert_called_once_with( - a.payload, context={"actor": a.actor, "inbox_items": [ii1, ii2]} + a.payload, context={"actor": a.actor, "activity": a, "inbox_items": [ii1, ii2]} ) ii1.refresh_from_db() diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index 54534e93b1..58c8f15405 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -105,9 +105,7 @@ def test_local_actor_inbox_post(factories, api_client, mocker, authenticated_act assert response.status_code == 200 patched_receive.assert_called_once_with( - activity={"hello": "world"}, - on_behalf_of=authenticated_actor, - recipient=user.actor, + activity={"hello": "world"}, on_behalf_of=authenticated_actor ) diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index ff49f24648..cd2651e72a 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -6,6 +6,7 @@ from django.urls import reverse from django.utils import timezone from funkwhale_api.music import serializers, tasks, views +from funkwhale_api.federation import api_serializers as federation_api_serializers DATA_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -384,3 +385,22 @@ def test_user_can_create_track_file( assert tf.import_reference == "test" assert tf.track is None m.assert_called_once_with(tasks.import_track_file.delay, track_file_id=tf.pk) + + +def test_user_can_list_own_library_follows(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + library = factories["music.Library"](actor=actor) + another_library = factories["music.Library"](actor=actor) + follow = factories["federation.LibraryFollow"](target=library) + factories["federation.LibraryFollow"](target=another_library) + + url = reverse("api:v1:libraries-follows", kwargs={"uuid": library.uuid}) + + response = logged_in_api_client.get(url) + + assert response.data == { + "count": 1, + "next": None, + "previous": None, + "results": [federation_api_serializers.LibraryFollowSerializer(follow).data], + } diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index 5ec0cc0d2c..2bde816f59 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -170,7 +170,10 @@ def test_creating_actor_from_user(factories, settings): def test_get_channels_groups(factories): user = factories["users.User"]() - assert user.get_channels_groups() == ["user.{}.imports".format(user.pk)] + assert user.get_channels_groups() == [ + "user.{}.imports".format(user.pk), + "user.{}.inbox".format(user.pk), + ] def test_user_quota_default_to_preference(factories, preferences): diff --git a/front/src/App.vue b/front/src/App.vue index 514f52d1c9..d96e91d92a 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -94,6 +94,8 @@ import axios from 'axios' import _ from 'lodash' import {mapState} from 'vuex' +import { WebSocketBridge } from 'django-channels' + import translations from '@/translations' @@ -113,11 +115,13 @@ export default { }, data () { return { + bridge: null, nodeinfo: null, instanceUrl: null } }, created () { + this.openWebsocket() let self = this this.autodetectLanguage() setInterval(() => { @@ -134,8 +138,23 @@ export default { this.$store.dispatch('auth/check') this.$store.dispatch('instance/fetchSettings') this.fetchNodeInfo() + this.$store.commit('ui/addWebsocketEventHandler', { + eventName: 'inbox.item_added', + id: 'sidebarCount', + handler: this.incrementNotificationCountInSidebar + }) + }, + destroyed () { + this.$store.commit('ui/removeWebsocketEventHandler', { + eventName: 'inbox.item_added', + id: 'sidebarCount', + }) + this.disconnect() }, methods: { + incrementNotificationCountInSidebar (event) { + this.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1}) + }, fetchNodeInfo () { let self = this axios.get('instance/nodeinfo/2.0/').then(response => { @@ -162,6 +181,36 @@ export default { } else if (almostMatching.length > 0) { this.$language.current = almostMatching[0] } + }, + disconnect () { + if (!this.bridge) { + return + } + this.bridge.socket.close(1000, 'goodbye', {keepClosed: true}) + }, + openWebsocket () { + if (!this.$store.state.auth.authenticated) { + return + } + this.disconnect() + let self = this + let token = this.$store.state.auth.token + // let token = 'test' + const bridge = new WebSocketBridge() + this.bridge = bridge + let url = this.$store.getters['instance/absoluteUrl'](`api/v1/activity?token=${token}`) + url = url.replace('http://', 'ws://') + url = url.replace('https://', 'wss://') + bridge.connect( + url, + null, + {reconnectInterval: 5000}) + bridge.listen(function (event) { + self.$store.dispatch('ui/websocketEvent', event) + }) + bridge.socket.addEventListener('open', function () { + console.log('Connected to WebSocket') + }) } }, computed: { @@ -189,6 +238,13 @@ export default { this.$store.dispatch('instance/fetchSettings') this.fetchNodeInfo() }, + '$store.state.auth.authenticated' (newValue) { + if (!newValue) { + this.disconnect() + } else { + this.openWebsocket() + } + }, '$language.current' (newValue) { this.$store.commit('ui/currentLanguage', newValue) } @@ -299,9 +355,11 @@ html, body { } } -.discrete.link { - color: rgba(0, 0, 0, 0.87); - cursor: pointer; +.discrete { + color: rgba(0, 0, 0, 0.87); +} +.link { + cursor: pointer; } .floated.buttons .button ~ .dropdown { @@ -321,5 +379,8 @@ html, body { a { cursor: pointer; } +.segment.hidden { + display: none; +} </style> diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 1688c37ebf..8d4afacf33 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -42,6 +42,15 @@ <img class="ui right floated circular tiny avatar image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" /> </router-link> <router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/settings'}"><i class="setting icon"></i><translate>Settings</translate></router-link> + <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'notifications'}"> + <i class="feed icon"></i> + <translate>Notifications</translate> + <div + v-if="$store.state.ui.notifications.inbox > 0" + :class="['ui', 'teal', 'label']"> + {{ $store.state.ui.notifications.inbox }}</div> + <img class="ui right floated circular tiny avatar image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" /> + </router-link> <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i><translate>Logout</translate></router-link> <template v-else> <router-link class="item" :to="{name: 'login'}"><i class="sign in icon"></i><translate>Login</translate></router-link> @@ -73,33 +82,6 @@ <div class="item" v-if="showAdmin"> <div class="header"><translate>Administration</translate></div> <div class="menu"> - <router-link - class="item" - v-if="$store.state.auth.availablePermissions['library']" - :to="{name: 'manage.library.files'}"> - <i class="book icon"></i><translate>Library</translate> - <div - :class="['ui', {'teal': $store.state.ui.notifications.importRequests > 0}, 'label']" - :title="labels.pendingRequests"> - {{ $store.state.ui.notifications.importRequests }}</div> - - </router-link> - <router-link - class="item" - v-else-if="$store.state.auth.availablePermissions['upload']" - to="/library/import/launch"> - <i class="download icon"></i><translate>Import music</translate> - </router-link> - <router-link - class="item" - v-if="$store.state.auth.availablePermissions['federation']" - :to="{path: '/manage/federation/libraries'}"> - <i class="sitemap icon"></i><translate>Federation</translate> - <div - :class="['ui', {'teal': $store.state.ui.notifications.federation > 0}, 'label']" - :title="labels.pendingFollows"> - {{ $store.state.ui.notifications.federation }}</div> - </router-link> <router-link class="item" v-if="$store.state.auth.availablePermissions['settings']" @@ -207,11 +189,6 @@ export default { mounted () { $(this.$el).find('.menu .item').tab() }, - created () { - this.fetchNotificationsCount() - this.fetchInterval = setInterval( - this.fetchNotificationsCount, 1000 * 60 * 15) - }, destroy () { if (this.fetchInterval) { clearInterval(this.fetchInterval) @@ -260,11 +237,6 @@ export default { return e }).length > 0 }, - - fetchNotificationsCount () { - this.$store.dispatch('ui/fetchFederationNotificationsCount') - this.$store.dispatch('ui/fetchImportRequestsCount') - }, reorder: function (event) { this.$store.commit('queue/reorder', { tracks: this.tracksChangeBuffer, oldIndex: event.oldIndex, newIndex: event.newIndex}) @@ -301,7 +273,6 @@ export default { '$store.state.auth.availablePermissions': { handler () { this.showAdmin = this.getShowAdmin() - this.fetchNotificationsCount() }, deep: true } diff --git a/front/src/components/common/ActorAvatar.vue b/front/src/components/common/ActorAvatar.vue new file mode 100644 index 0000000000..be21eb5816 --- /dev/null +++ b/front/src/components/common/ActorAvatar.vue @@ -0,0 +1,21 @@ +<template> + <span :style="defaultAvatarStyle" class="ui avatar circular label">{{ actor.preferred_username[0]}}</span> +</template> + +<script> +import {hashCode, intToRGB} from '@/utils/color' + +export default { + props: ['actor'], + computed: { + actorColor () { + return intToRGB(hashCode(this.actor.full_username)) + }, + defaultAvatarStyle () { + return { + 'background-color': `#${this.actorColor}` + } + } + } +} +</script> diff --git a/front/src/components/common/ActorLink.vue b/front/src/components/common/ActorLink.vue index e03c199242..518e67c863 100644 --- a/front/src/components/common/ActorLink.vue +++ b/front/src/components/common/ActorLink.vue @@ -1,6 +1,6 @@ <template> <span :title="actor.full_username"> - <span :style="defaultAvatarStyle" class="ui circular label">{{ actor.preferred_username[0]}}</span> + <actor-avatar v-if="avatar" :actor="actor" /> {{ actor.full_username | truncate(30) }} </span> </template> @@ -9,22 +9,9 @@ import {hashCode, intToRGB} from '@/utils/color' export default { - props: ['actor'], - computed: { - actorColor () { - return intToRGB(hashCode(this.actor.full_username)) - }, - defaultAvatarStyle () { - return { - 'background-color': `#${this.actorColor}` - } - } + props: { + actor: {type: Object}, + avatar: {type: Boolean, default: true} } } </script> -<style scoped> -.tiny.circular.avatar { - width: 1.7em; - height: 1.7em; -} -</style> diff --git a/front/src/components/globals.js b/front/src/components/globals.js index ee2deaacb3..f3bb383f06 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -16,6 +16,10 @@ import ActorLink from '@/components/common/ActorLink' Vue.component('actor-link', ActorLink) +import ActorAvatar from '@/components/common/ActorAvatar' + +Vue.component('actor-avatar', ActorAvatar) + import Duration from '@/components/common/Duration' Vue.component('duration', Duration) diff --git a/front/src/components/library/FileUpload.vue b/front/src/components/library/FileUpload.vue index e703f671cd..f73c8a3af5 100644 --- a/front/src/components/library/FileUpload.vue +++ b/front/src/components/library/FileUpload.vue @@ -1,24 +1,7 @@ <template> <div> - <div class="ui hidden clearing divider"></div> - <!-- <div v-if="files.length > 0" class="ui indicating progress"> - <div class="bar"></div> - <div class="label"> - {{ uploadedFilesCount }}/{{ files.length }} files uploaded, - {{ processedFilesCount }}/{{ processableFiles }} files processed - </div> - </div> --> - <div class="ui form"> - <div class="fields"> - <div class="ui four wide field"> - <label><translate>Import reference</translate></label> - <input type="text" v-model="importReference" /> - </div> - </div> - - </div> - <p><translate>This reference will be used to group imported files together.</translate></p> <div class="ui top attached tabular menu"> + <a :class="['item', {active: currentTab === 'summary'}]" @click="currentTab = 'summary'"><translate>Summary</translate></a> <a :class="['item', {active: currentTab === 'uploads'}]" @click="currentTab = 'uploads'"> <translate>Uploading</translate> <div v-if="files.length === 0" class="ui label"> @@ -44,6 +27,37 @@ </div> </a> </div> + + <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'summary'}]"> + <h2 class="ui header"><translate>Upload new tracks</translate></h2> + <div class="ui message"> + <p><translate>You are about to upload music to your library. Before proceeding, please ensure that:</translate></p> + <ul> + <li v-if="library.privacy_level != 'me'"> + You are not uploading copyrighted content in a public library, otherwise you may be infringing the law + </li> + <li> + <translate>The music files you are uploading are tagged properly:</translate> + <a href="http://picard.musicbrainz.org/" target='_blank'><translate>we recommend using Picard for that purpose</translate></a> + </li> + <li> + <translate>The uploaded music files are in OGG, Flac or MP3 format</translate> + </li> + </ul> + </div> + + <div class="ui form"> + <div class="fields"> + <div class="ui four wide field"> + <label><translate>Import reference</translate></label> + <p><translate>This reference will be used to group imported files together.</translate></p> + <input type="text" v-model="importReference" /> + </div> + </div> + + </div> + <div class="ui green button" @click="currentTab = 'uploads'"><translate>Proceed</translate></div> + </div> <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'uploads'}]"> <div class="ui container"> <file-upload-widget @@ -114,7 +128,6 @@ import logger from '@/logging' import FileUploadWidget from './FileUploadWidget' import LibraryFilesTable from '@/views/content/libraries/FilesTable' import moment from 'moment' -import { WebSocketBridge } from 'django-channels' export default { props: ['library', 'defaultImportReference'], @@ -127,7 +140,7 @@ export default { this.$router.replace({query: {import: importReference}}) return { files: [], - currentTab: 'uploads', + currentTab: 'summary', uploadUrl: '/api/v1/track-files/', importReference, trackFiles: { @@ -137,18 +150,23 @@ export default { errored: 0, objects: {}, }, - bridge: null, processTimestamp: new Date() } }, created () { - this.openWebsocket() this.fetchStatus() + this.$store.commit('ui/addWebsocketEventHandler', { + eventName: 'import.status_updated', + id: 'fileUpload', + handler: this.handleImportEvent + }) }, destroyed () { - this.disconnect() + this.$store.commit('ui/removeWebsocketEventHandler', { + eventName: 'import.status_updated', + id: 'fileUpload', + }) }, - methods: { inputFilter (newFile, oldFile, prevent) { if (newFile && !oldFile) { @@ -199,20 +217,17 @@ export default { console.log('Connected to WebSocket') }) }, - handleEvent (event) { - console.log('Received event', event.type, event) + handleImportEvent (event) { let self = this - if (event.type === 'import.status_updated') { - if (event.track_file.import_reference != self.importReference) { - return - } - this.$nextTick(() => { - self.trackFiles[event.old_status] -= 1 - self.trackFiles[event.new_status] += 1 - self.trackFiles.objects[event.track_file.uuid] = event.track_file - self.triggerReload() - }) + if (event.track_file.import_reference != self.importReference) { + return } + this.$nextTick(() => { + self.trackFiles[event.old_status] -= 1 + self.trackFiles[event.new_status] += 1 + self.trackFiles.objects[event.track_file.uuid] = event.track_file + self.triggerReload() + }) }, triggerReload: _.throttle(function () { this.processTimestamp = new Date() @@ -298,7 +313,4 @@ export default { border: 3px solid rgba(50, 50, 50, 0.5); font-size: 1.5em; } -.segment.hidden { - display: none; -} </style> diff --git a/front/src/components/notifications/NotificationRow.vue b/front/src/components/notifications/NotificationRow.vue new file mode 100644 index 0000000000..db5918c473 --- /dev/null +++ b/front/src/components/notifications/NotificationRow.vue @@ -0,0 +1,126 @@ +<template> + <tr :class="[{'disabled-row': item.is_read}]"> + <td> + <actor-link class="user" :actor="item.activity.actor" /> + </td> + <td> + <router-link tag="span" class="link" v-if="notificationData.detailUrl" :to="notificationData.detailUrl"> + {{ notificationData.message }} + </router-link> + <template v-else>{{ notificationData.message }}</template> + <template v-if="notificationData.action"> + <div @click="handleAction(notificationData.action.handler)" :class="['ui', 'basic', 'tiny', notificationData.action.buttonClass || '', 'button']"> + <i v-if="notificationData.action.icon" :class="[notificationData.action.icon, 'icon']" /> + {{ notificationData.action.label }} + </div> + </template> + </td> + <td><human-date :date="item.activity.creation_date" /></td> + <td class="read collapsing"> + <span @click="markRead(false)" v-if="item.is_read" :title="labels.markUnread"> + <i class="redo icon" /> + </span> + <span @click="markRead(true)" v-else :title="labels.markRead"> + <i class="check icon" /> + </span> + </td> + </tr> +</template> +<script> +import axios from 'axios' + +export default { + props: ['item'], + computed: { + message () { + return 'plop' + }, + labels () { + let libraryFollowMessage = this.$gettext('%{ username } followed your library "%{ library }"') + let libraryAcceptFollowMessage = this.$gettext('%{ username } accepted your follow on library "%{ library }"') + return { + libraryFollowMessage, + libraryAcceptFollowMessage, + markRead: this.$gettext('Mark as read'), + markUnread: this.$gettext('Mark as unread'), + + } + }, + username () { + return this.item.activity.actor.preferred_username + }, + notificationData () { + let self = this + let a = this.item.activity + if (a.type === 'Follow') { + if (a.object && a.object.type === 'music.Library') { + let action = null + if (!a.related_object.approved) { + action = { + buttonClass: 'green', + icon: 'check', + label: this.$gettext('Approve'), + handler: () => { self.approveLibraryFollow(a.related_object) } + } + } + return { + action, + detailUrl: {name: 'content.libraries.detail', params: {id: a.object.uuid}}, + message: this.$gettextInterpolate( + this.labels.libraryFollowMessage, + {username: this.username, library: a.object.name} + ) + } + } + } + if (a.type === 'Accept') { + if (a.object && a.object.type === 'federation.LibraryFollow') { + return { + detailUrl: {name: 'content.remote.index'}, + message: this.$gettextInterpolate( + this.labels.libraryAcceptFollowMessage, + {username: this.username, library: a.related_object.name} + ) + } + } + } + return {} + } + }, + methods: { + handleAction (handler) { + // call handler then mark notification as read + handler() + this.markRead(true) + }, + approveLibraryFollow (follow) { + let self = this + let action = 'accept' + axios.post(`federation/follows/library/${follow.uuid}/${action}/`).then((response) => { + follow.isLoading = false + follow.approved = true + }) + }, + markRead (value) { + let self = this + let action = 'accept' + axios.patch(`federation/inbox/${this.item.id}/`, {is_read: value}).then((response) => { + self.item.is_read = value + if (value) { + self.$store.commit('ui/incrementNotifications', {type: 'inbox', count: -1}) + } else { + self.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1}) + } + }) + } + } +} +</script> +<style scoped> +.read > span { + cursor: pointer; +} +.disabled-row { + color: rgba(40, 40, 40, 0.3); +} +</style> diff --git a/front/src/router/index.js b/front/src/router/index.js index ad87ef54e1..ff7e4469f5 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -46,6 +46,7 @@ import LibrariesUpload from '@/views/content/libraries/Upload' import LibrariesDetail from '@/views/content/libraries/Detail' import LibrariesFiles from '@/views/content/libraries/Files' import RemoteLibrariesHome from '@/views/content/remote/Home' +import Notifications from '@/views/Notifications' Vue.use(Router) @@ -74,6 +75,11 @@ export default new Router({ component: Login, props: (route) => ({ next: route.query.next || '/library' }) }, + { + path: '/notifications', + name: 'notifications', + component: Notifications + }, { path: '/auth/password/reset', name: 'auth.password-reset', diff --git a/front/src/store/auth.js b/front/src/store/auth.js index 098dab7087..4100981bbb 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -117,6 +117,7 @@ export default { commit('authenticated', true) commit('profile', data) commit('username', data.username) + dispatch('ui/fetchUnreadNotifications', null, { root: true }) dispatch('favorites/fetch', null, { root: true }) dispatch('playlists/fetchOwn', null, { root: true }) Object.keys(data.permissions).forEach(function (key) { diff --git a/front/src/store/ui.js b/front/src/store/ui.js index 6641f4c0de..da08754962 100644 --- a/front/src/store/ui.js +++ b/front/src/store/ui.js @@ -9,11 +9,20 @@ export default { messageDisplayDuration: 10000, messages: [], notifications: { - federation: 0, - importRequests: 0 + inbox: 0, + }, + websocketEventsHandlers: { + 'inbox.item_added': {}, + 'import.status_updated': {}, } }, mutations: { + addWebsocketEventHandler: (state, {eventName, id, handler}) => { + state.websocketEventsHandlers[eventName][id] = handler + }, + removeWebsocketEventHandler: (state, {eventName, id}) => { + delete state.websocketEventsHandlers[eventName][id] + }, currentLanguage: (state, value) => { state.currentLanguage = value }, @@ -28,23 +37,27 @@ export default { }, notifications (state, {type, count}) { state.notifications[type] = count + }, + incrementNotifications (state, {type, count}) { + state.notifications[type] = Math.max(0, state.notifications[type] + count) } }, actions: { - fetchFederationNotificationsCount ({rootState, commit}) { - if (!rootState.auth.availablePermissions['federation']) { - return - } - axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => { - commit('notifications', {type: 'federation', count: response.data.count}) + fetchUnreadNotifications ({commit}, payload) { + axios.get('federation/inbox/', {params: {is_read: false, page_size: 1}}).then((response) => { + commit('notifications', {type: 'inbox', count: response.data.count}) }) }, - fetchImportRequestsCount ({rootState, commit}) { - if (!rootState.auth.availablePermissions['library']) { + websocketEvent ({state}, event) { + console.log('Dispatching websocket event', event) + let handlers = state.websocketEventsHandlers[event.type] + if (!handlers) { return } - axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => { - commit('notifications', {type: 'importRequests', count: response.data.count}) + let names = Object.keys(handlers) + names.forEach((k) => { + let handler = handlers[k] + handler(event) }) } } diff --git a/front/src/views/Notifications.vue b/front/src/views/Notifications.vue new file mode 100644 index 0000000000..758283c779 --- /dev/null +++ b/front/src/views/Notifications.vue @@ -0,0 +1,125 @@ +<template> + <div class="main pusher" v-title="labels.title"> + <div class="ui vertical aligned stripe segment"> + <div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']"> + <div class="ui text loader"><translate>Loading notifications...</translate></div> + </div> + <div v-else class="ui container"> + <h1 class="ui header"><translate>Your notifications</translate></h1> + <div class="ui toggle checkbox"> + <input v-model="filters.is_read" type="checkbox"> + <label><translate>Show read notifications</translate></label> + </div> + <div + v-if="filters.is_read === false && notifications.count > 0" + @click="markAllAsRead" + class="ui basic labeled icon right floated button"> + <i class="ui check icon" /> + <translate>Mark all as read</translate> + </div> + <div class="ui hidden divider" /> + <table v-if="notifications.count > 0" class="ui table"> + <tbody> + <notification-row :item="item" v-for="item in notifications.results" :key="item.id" /> + </tbody> + </table> + <p v-else> + <translate>We don't have any notification to display!</translate> + </p> + </div> + </div> + </div> +</template> + +<script> +import {mapState} from 'vuex' +import axios from 'axios' +import logger from '@/logging' + +import NotificationRow from '@/components/notifications/NotificationRow' + +export default { + data () { + return { + isLoading: false, + notifications: null, + filters: { + is_read: false + } + } + }, + components: { + NotificationRow + }, + created () { + this.fetch(this.filters) + this.$store.commit('ui/addWebsocketEventHandler', { + eventName: 'inbox.item_added', + id: 'notificationPage', + handler: this.handleNewNotification + }) + }, + destroyed () { + this.$store.commit('ui/removeWebsocketEventHandler', { + eventName: 'inbox.item_added', + id: 'notificationPage', + }) + }, + computed: { + ...mapState({ + events: state => state.instance.events + }), + labels () { + return { + title: this.$gettext('Notifications'), + } + } + }, + methods: { + handleNewNotification (event) { + this.notifications.results.unshift(event.item) + }, + fetch (params) { + this.isLoading = true + let self = this + axios.get('federation/inbox/', {params: params}).then((response) => { + self.isLoading = false + self.notifications = response.data + }) + }, + markAllAsRead () { + let self = this + let before = this.notifications.results[0].id + let payload = { + action: 'read', + objects: 'all', + filters: { + is_read: false, + before + } + } + axios.post('federation/inbox/action/', payload).then((response) => { + self.$store.commit('ui/notifications', {type: 'inbox', count: 0}) + self.notifications.results.forEach(n => { + n.is_read = true + }) + + }) + }, + }, + watch: { + 'filters.is_read' () { + this.fetch(this.filters) + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style> +.event .ui.label.avatar { + font-size: 1.5em; + position: relative; + top: 0.35em; +} +</style> diff --git a/front/src/views/content/libraries/Detail.vue b/front/src/views/content/libraries/Detail.vue index 661ac92e61..eed851e5e5 100644 --- a/front/src/views/content/libraries/Detail.vue +++ b/front/src/views/content/libraries/Detail.vue @@ -4,14 +4,12 @@ <div class="ui text loader"><translate>Loading library data...</translate></div> </div> <detail-area v-else :library="library"> - <div slot="header"> - <h2 class="ui header"><translate>Manage</translate></h2> - <p><a @click="hiddenForm = !hiddenForm"> - <i class="pencil icon" /> - <translate>Edit library</translate> - </a></p> - <library-form v-if="!hiddenForm" :library="library" @updated="libraryUpdated" @deleted="libraryDeleted" /> - <div class="ui hidden divider"></div> + <div class="ui top attached tabular menu"> + <a :class="['item', {active: currentTab === 'follows'}]" @click="currentTab = 'follows'"><translate>Followers</translate></a> + <a :class="['item', {active: currentTab === 'tracks'}]" @click="currentTab = 'tracks'"><translate>Tracks</translate></a> + <a :class="['item', {active: currentTab === 'edit'}]" @click="currentTab = 'edit'"><translate>Edit</translate></a> + </div> + <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'follows'}]"> <div class="ui form"> <div class="field"> <label><translate>Sharing link</translate></label> @@ -19,14 +17,58 @@ <copy-input :value="library.fid" /> </div> </div> + <div class="ui hidden divider"></div> + <div v-if="isLoadingFollows" :class="['ui', {'active': isLoadingFollows}, 'inverted', 'dimmer']"> + <div class="ui text loader"><translate>Loading followers...</translate></div> + </div> + <table v-else-if="follows && follows.count > 0" class="ui table"> + <thead> + <tr> + <th><translate>User</translate></th> + <th><translate>Date</translate></th> + <th><translate>Status</translate></th> + <th><translate>Action</translate></th> + </tr> + </thead> + <tr v-for="follow in follows.results" :key="follow.fid"> + <td><actor-link :actor="follow.actor" /></td> + <td><human-date :date="follow.creation_date" /></td> + <td> + <span :class="['ui', 'yellow', 'basic', 'label']" v-if="follow.approved === null"> + <translate>Pending approval</translate> + </span> + <span :class="['ui', 'green', 'basic', 'label']" v-else-if="follow.approved === true"> + <translate>Accepted</translate> + </span> + <span :class="['ui', 'red', 'basic', 'label']" v-else-if="follow.approved === false"> + <translate>Rejected</translate> + </span> + </td> + <td> + <div @click="updateApproved(follow, true)" :class="['ui', 'mini', 'icon', 'labeled', 'green', 'button']" v-if="follow.approved === null || follow.approved === false"> + <i class="ui check icon"></i> <translate>Accept</translate> + </div> + <div @click="updateApproved(follow, false)" :class="['ui', 'mini', 'icon', 'labeled', 'red', 'button']" v-if="follow.approved === null || follow.approved === true"> + <i class="ui x icon"></i> <translate>Reject</translate> + </div> + </td> + </tr> + + </table> + <p v-else><translate>Nobody is following this library</translate></p> + </div> + <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'tracks'}]"> + <library-files-table :filters="{library: library.uuid}"></library-files-table> + </div> + <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'edit'}]"> + <library-form :library="library" @updated="libraryUpdated" @deleted="libraryDeleted" /> </div> - <h2><translate>Tracks</translate></h2> - <library-files-table :filters="{library: library.uuid}"></library-files-table> </detail-area> </div> </template> <script> +import axios from 'axios' import DetailMixin from './DetailMixin' import DetailArea from './DetailArea' import LibraryForm from './Form' @@ -41,9 +83,14 @@ export default { }, data () { return { - hiddenForm: true + currentTab: 'follows', + isLoadingFollows: false, + follows: null } }, + created () { + this.fetchFollows() + }, methods: { libraryUpdated () { this.hiddenForm = true @@ -53,6 +100,27 @@ export default { this.$router.push({ name: 'content.libraries.index' }) + }, + fetchFollows () { + let self = this + self.isLoadingLibrary = true + axios.get(`libraries/${this.id}/follows/`).then((response) => { + self.follows = response.data + self.isLoadingFollows = false + }) + }, + updateApproved (follow, value) { + let self = this + let action + if (value) { + action = 'accept' + } else { + action = 'reject' + } + axios.post(`federation/follows/library/${follow.uuid}/${action}/`).then((response) => { + follow.isLoading = false + follow.approved = value + }) } } } diff --git a/front/src/views/content/libraries/DetailArea.vue b/front/src/views/content/libraries/DetailArea.vue index 677984fbbd..eb481ea881 100644 --- a/front/src/views/content/libraries/DetailArea.vue +++ b/front/src/views/content/libraries/DetailArea.vue @@ -1,15 +1,12 @@ <template> <div> <div class="ui stackable grid"> - <div class="eleven wide stretched column"> - <slot name="header"></slot> - </div> <div class="five wide column"> <h3 class="ui header"><translate>Current library</translate></h3> <library-card :library="library" /> </div> </div> - <div class="ui divider"></div> + <div class="ui hidden divider"></div> <slot></slot> </div> </template> diff --git a/front/src/views/content/libraries/Upload.vue b/front/src/views/content/libraries/Upload.vue index 68ee3b9338..6df698b2c2 100644 --- a/front/src/views/content/libraries/Upload.vue +++ b/front/src/views/content/libraries/Upload.vue @@ -4,24 +4,6 @@ <div class="ui text loader"><translate>Loading library data...</translate></div> </div> <detail-area v-else :library="library"> - <div slot="header"> - <h2 class="ui header"><translate>Upload new tracks</translate></h2> - <div class="ui message"> - <p><translate>You are about to upload music to your library. Before proceeding, please ensure that:</translate></p> - <ul> - <li v-if="library.privacy_level != 'me'"> - You are not uploading copyrighted content in a public library, otherwise you may be infringing the law - </li> - <li> - <translate>The music files you are uploading are tagged properly:</translate> - <a href="http://picard.musicbrainz.org/" target='_blank'><translate>we recommend using Picard for that purpose</translate></a> - </li> - <li> - <translate>The uploaded music files are in OGG, Flac or MP3 format</translate> - </li> - </ul> - </div> - </div> <file-upload :default-import-reference="defaultImportReference" :library="library" /> </detail-area> </div> diff --git a/front/src/views/content/remote/Card.vue b/front/src/views/content/remote/Card.vue index b9903d94b7..074771261d 100644 --- a/front/src/views/content/remote/Card.vue +++ b/front/src/views/content/remote/Card.vue @@ -47,6 +47,11 @@ class="ui disabled button"><i class="check icon"></i> <translate>Following</translate> </button> + <button + v-else-if="library.follow.approved" + class="ui button"><i class="x icon"></i> + <translate>Unfollow</translate> + </button> </div> </div> </template> diff --git a/front/src/views/content/remote/Home.vue b/front/src/views/content/remote/Home.vue index 753e5fa2b0..d781f696ef 100644 --- a/front/src/views/content/remote/Home.vue +++ b/front/src/views/content/remote/Home.vue @@ -11,6 +11,12 @@ <div v-if="scanResult && scanResult.results.length > 0" class="ui two cards"> <library-card :library="library" v-for="library in scanResult.results" :key="library.fid" /> </div> + <template v-if="existingFollows && existingFollows.count > 0"> + <h2><translate>Known libraries</translate></h2> + <div class="ui two cards"> + <library-card :library="getLibraryFromFollow(follow)" v-for="follow in existingFollows.results" :key="follow.fid" /> + </div> + </template> </div> </div> </template> @@ -24,11 +30,12 @@ export default { data () { return { isLoading: false, - scanResult: null + scanResult: null, + existingFollows: null } }, created () { - // this.fetch() + this.fetch() }, components: { ScanForm, @@ -38,13 +45,17 @@ export default { fetch () { this.isLoading = true let self = this - axios.get('libraries/').then((response) => { + axios.get('federation/follows/library/', {params: {'page_size': 100, 'ordering': '-creation_date'}}).then((response) => { + self.existingFollows = response.data + self.isLoading = false + }, error => { self.isLoading = false - self.libraries = response.data.results - if (self.libraries.length === 0) { - self.hiddenForm = false - } }) + }, + getLibraryFromFollow (follow) { + let d = follow.target + d.follow = follow + return d } } } -- GitLab