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" />
     &nbsp;{{ 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">&nbsp;
+        <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