diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 0fa450c46c763c54153855a1733a1e92690933b3..e4accd722d66823a067fe8f2c1212746bc18b076 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -22,7 +22,7 @@ test_api:
   variables:
     DJANGO_ALLOWED_HOSTS: "localhost"
     DATABASE_URL: "postgresql://postgres@postgres/postgres"
-
+    FUNKWHALE_URL: "https://funkwhale.ci"
   before_script:
     - cd api
     - pip install -r requirements/base.txt
diff --git a/api/Dockerfile b/api/Dockerfile
index 5d4e858574a0063b756a168f48b529e904bc427b..9296785eef25d9b5a14cd9e140b8461ba188eeff 100644
--- a/api/Dockerfile
+++ b/api/Dockerfile
@@ -5,7 +5,11 @@ ENV PYTHONUNBUFFERED 1
 # Requirements have to be pulled and installed here, otherwise caching won't work
 RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list
 COPY ./requirements.apt /requirements.apt
-RUN apt-get update -qq && grep "^[^#;]" requirements.apt | xargs apt-get install -y
+RUN apt-get update; \
+    grep "^[^#;]" requirements.apt | \
+    grep -Fv "python3-dev" | \
+    xargs apt-get install -y --no-install-recommends; \
+    rm -rf /usr/share/doc/* /usr/share/locale/*
 RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1
 COPY ./requirements/base.txt /requirements/base.txt
 RUN pip install -r /requirements/base.txt
@@ -20,3 +24,4 @@ RUN pip install --upgrade youtube-dl
 WORKDIR /app
 
 ENTRYPOINT ["./compose/django/entrypoint.sh"]
+CMD ["./compose/django/daphne.sh"]
diff --git a/api/compose/django/daphne.sh b/api/compose/django/daphne.sh
new file mode 100755
index 0000000000000000000000000000000000000000..16b4d50b800d8177bafe5027aa571509c0d2c149
--- /dev/null
+++ b/api/compose/django/daphne.sh
@@ -0,0 +1,3 @@
+#!/bin/bash -eux
+python /app/manage.py collectstatic --noinput
+/usr/local/bin/daphne --root-path=/app -b 0.0.0.0 -p 5000 config.asgi:application
diff --git a/api/compose/django/gunicorn.sh b/api/compose/django/gunicorn.sh
deleted file mode 100755
index 014f173e335808e24af6ff776d0c2cb7512978f0..0000000000000000000000000000000000000000
--- a/api/compose/django/gunicorn.sh
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/bin/sh
-python /app/manage.py collectstatic --noinput
-/usr/local/bin/gunicorn config.wsgi -w 4 -b 0.0.0.0:5000 --chdir=/app
\ No newline at end of file
diff --git a/api/config/asgi.py b/api/config/asgi.py
new file mode 100644
index 0000000000000000000000000000000000000000..b976a02ebdde045933f4a2dd1997abf2ebdadddb
--- /dev/null
+++ b/api/config/asgi.py
@@ -0,0 +1,8 @@
+import django
+import os
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
+
+django.setup()
+
+from .routing import application
diff --git a/api/config/routing.py b/api/config/routing.py
new file mode 100644
index 0000000000000000000000000000000000000000..574d5a18e2eba402b7b3c7620f195d83767b632e
--- /dev/null
+++ b/api/config/routing.py
@@ -0,0 +1,18 @@
+from django.conf.urls import url
+
+from channels.auth import AuthMiddlewareStack
+from channels.routing import ProtocolTypeRouter, URLRouter
+
+from funkwhale_api.common.auth import TokenAuthMiddleware
+from funkwhale_api.instance import consumers
+
+
+application = ProtocolTypeRouter({
+    # Empty for now (http->django views is added by default)
+    "websocket": TokenAuthMiddleware(
+        URLRouter([
+            url("^api/v1/instance/activity$",
+                consumers.InstanceActivityConsumer),
+        ])
+    ),
+})
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index f5ddec00b1da2f591f1a651e1b9c60427d1f4da1..bff43b233481b45a91c1e2ff53d1e235c6743838 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -25,11 +25,12 @@ except FileNotFoundError:
     pass
 
 ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
-
+FUNKWHALE_URL = env('FUNKWHALE_URL')
 
 # APP CONFIGURATION
 # ------------------------------------------------------------------------------
 DJANGO_APPS = (
+    'channels',
     # Default Django apps:
     'django.contrib.auth',
     'django.contrib.contenttypes',
@@ -82,6 +83,7 @@ if RAVEN_ENABLED:
 # Apps specific for this project go here.
 LOCAL_APPS = (
     'funkwhale_api.common',
+    'funkwhale_api.activity.apps.ActivityConfig',
     'funkwhale_api.users',  # custom users app
     # Your stuff: custom apps go here
     'funkwhale_api.instance',
@@ -253,9 +255,9 @@ MEDIA_URL = env("MEDIA_URL", default='/media/')
 # URL Configuration
 # ------------------------------------------------------------------------------
 ROOT_URLCONF = 'config.urls'
-
 # See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
 WSGI_APPLICATION = 'config.wsgi.application'
+ASGI_APPLICATION = "config.routing.application"
 
 # AUTHENTICATION CONFIGURATION
 # ------------------------------------------------------------------------------
@@ -284,6 +286,17 @@ CACHES = {
 }
 
 CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache"
+from urllib.parse import urlparse
+cache_url = urlparse(CACHES['default']['LOCATION'])
+CHANNEL_LAYERS = {
+    "default": {
+        "BACKEND": "channels_redis.core.RedisChannelLayer",
+        "CONFIG": {
+            "hosts": [(cache_url.hostname, cache_url.port)],
+        },
+    },
+}
+
 CACHES["default"]["OPTIONS"] = {
     "CLIENT_CLASS": "django_redis.client.DefaultClient",
     "IGNORE_EXCEPTIONS": True,  # mimics memcache behavior.
diff --git a/api/config/settings/production.py b/api/config/settings/production.py
index df15d325f22d8d78616c937a3142b4a11b34ded8..f238c2d20b9e2de5a2baf8136aa7eca1f6fe40d8 100644
--- a/api/config/settings/production.py
+++ b/api/config/settings/production.py
@@ -58,8 +58,6 @@ CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
 
 # END SITE CONFIGURATION
 
-INSTALLED_APPS += ("gunicorn", )
-
 # STORAGE CONFIGURATION
 # ------------------------------------------------------------------------------
 # Uploaded Media Files
diff --git a/api/docker/Dockerfile.test b/api/docker/Dockerfile.test
index 069b89c2f83de1628d485374545f908fe52ec93c..00638e9dd3cb5d290d336698a842f826d73583e9 100644
--- a/api/docker/Dockerfile.test
+++ b/api/docker/Dockerfile.test
@@ -1,13 +1,16 @@
 FROM python:3.5
 
 ENV PYTHONUNBUFFERED 1
-ENV PYTHONDONTWRITEBYTECODE 1
 
 # Requirements have to be pulled and installed here, otherwise caching won't work
 RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list
 COPY ./requirements.apt /requirements.apt
-COPY ./install_os_dependencies.sh /install_os_dependencies.sh
-RUN bash install_os_dependencies.sh install
+RUN apt-get update; \
+    grep "^[^#;]" requirements.apt | \
+    grep -Fv "python3-dev" | \
+    xargs apt-get install -y --no-install-recommends; \
+    rm -rf /usr/share/doc/* /usr/share/locale/*
+
 RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1
 
 RUN mkdir /requirements
@@ -18,4 +21,5 @@ RUN pip install -r /requirements/local.txt
 COPY ./requirements/test.txt /requirements/test.txt
 RUN pip install -r /requirements/test.txt
 
+COPY . /app
 WORKDIR /app
diff --git a/api/funkwhale_api/activity/__init__.py b/api/funkwhale_api/activity/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/activity/apps.py b/api/funkwhale_api/activity/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..0c66cbf50cf2542499ccce1fbdb262a13600324d
--- /dev/null
+++ b/api/funkwhale_api/activity/apps.py
@@ -0,0 +1,12 @@
+from django.apps import AppConfig, apps
+
+from . import record
+
+class ActivityConfig(AppConfig):
+    name = 'funkwhale_api.activity'
+
+    def ready(self):
+        super(ActivityConfig, self).ready()
+
+        app_names = [app.name for app in apps.app_configs.values()]
+        record.registry.autodiscover(app_names)
diff --git a/api/funkwhale_api/activity/record.py b/api/funkwhale_api/activity/record.py
new file mode 100644
index 0000000000000000000000000000000000000000..fa55c0e85288318acd2f3d41ab02de9805eb8632
--- /dev/null
+++ b/api/funkwhale_api/activity/record.py
@@ -0,0 +1,38 @@
+import persisting_theory
+
+
+class ActivityRegistry(persisting_theory.Registry):
+    look_into = 'activities'
+
+    def _register_for_model(self, model, attr, value):
+        key = model._meta.label
+        d = self.setdefault(key, {'consumers': []})
+        d[attr] = value
+
+    def register_serializer(self, serializer_class):
+        model = serializer_class.Meta.model
+        self._register_for_model(model, 'serializer', serializer_class)
+        return serializer_class
+
+    def register_consumer(self, label):
+        def decorator(func):
+            consumers = self[label]['consumers']
+            if func not in consumers:
+                consumers.append(func)
+            return func
+        return decorator
+
+
+registry = ActivityRegistry()
+
+
+
+
+def send(obj):
+    conf = registry[obj.__class__._meta.label]
+    consumers = conf['consumers']
+    if not consumers:
+        return
+    serializer = conf['serializer'](obj)
+    for consumer in consumers:
+        consumer(data=serializer.data, obj=obj)
diff --git a/api/funkwhale_api/activity/serializers.py b/api/funkwhale_api/activity/serializers.py
new file mode 100644
index 0000000000000000000000000000000000000000..325d1e820db5699abca69b57b3421b0e0ca1d68b
--- /dev/null
+++ b/api/funkwhale_api/activity/serializers.py
@@ -0,0 +1,10 @@
+from rest_framework import serializers
+
+
+class ModelSerializer(serializers.ModelSerializer):
+    id = serializers.CharField(source='get_activity_url')
+    local_id = serializers.IntegerField(source='id')
+    # url = serializers.SerializerMethodField()
+
+    def get_url(self, obj):
+        return self.get_id(obj)
diff --git a/api/funkwhale_api/common/auth.py b/api/funkwhale_api/common/auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..6f99b3bba26b36fbce61dd03fb608cb2cb61f6ba
--- /dev/null
+++ b/api/funkwhale_api/common/auth.py
@@ -0,0 +1,47 @@
+from urllib.parse import parse_qs
+
+import jwt
+
+from django.contrib.auth.models import AnonymousUser
+from django.utils.encoding import smart_text
+
+from rest_framework import exceptions
+from rest_framework_jwt.settings import api_settings
+from rest_framework_jwt.authentication import BaseJSONWebTokenAuthentication
+
+
+
+class TokenHeaderAuth(BaseJSONWebTokenAuthentication):
+    def get_jwt_value(self, request):
+
+        try:
+            qs = request.get('query_string', b'').decode('utf-8')
+            parsed = parse_qs(qs)
+            token = parsed['token'][0]
+        except KeyError:
+            raise exceptions.AuthenticationFailed('No token')
+
+        if not token:
+            raise exceptions.AuthenticationFailed('Empty token')
+
+        return token
+
+
+class TokenAuthMiddleware:
+    """
+    Custom middleware (insecure) that takes user IDs from the query string.
+    """
+
+    def __init__(self, inner):
+        # Store the ASGI application we were passed
+        self.inner = inner
+
+    def __call__(self, scope):
+        auth = TokenHeaderAuth()
+        try:
+            user, token = auth.authenticate(scope)
+        except exceptions.AuthenticationFailed:
+            user = AnonymousUser()
+
+        scope['user'] = user
+        return self.inner(scope)
diff --git a/api/funkwhale_api/common/channels.py b/api/funkwhale_api/common/channels.py
new file mode 100644
index 0000000000000000000000000000000000000000..a009ab5abf4bce698897cbacef0b263a49d5208e
--- /dev/null
+++ b/api/funkwhale_api/common/channels.py
@@ -0,0 +1,6 @@
+from asgiref.sync import async_to_sync
+from channels.layers import get_channel_layer
+
+channel_layer = get_channel_layer()
+group_send = async_to_sync(channel_layer.group_send)
+group_add = async_to_sync(channel_layer.group_add)
diff --git a/api/funkwhale_api/common/consumers.py b/api/funkwhale_api/common/consumers.py
new file mode 100644
index 0000000000000000000000000000000000000000..300ce5e26e50068c88e91edd5dcedef5ce3de412
--- /dev/null
+++ b/api/funkwhale_api/common/consumers.py
@@ -0,0 +1,17 @@
+from channels.generic.websocket import JsonWebsocketConsumer
+from funkwhale_api.common import channels
+
+
+class JsonAuthConsumer(JsonWebsocketConsumer):
+    def connect(self):
+        try:
+            assert self.scope['user'].pk is not None
+        except (AssertionError, AttributeError, KeyError):
+            return self.close()
+
+        return self.accept()
+
+    def accept(self):
+        super().accept()
+        for group in self.groups:
+            channels.group_add(group, self.channel_name)
diff --git a/api/funkwhale_api/favorites/activities.py b/api/funkwhale_api/favorites/activities.py
new file mode 100644
index 0000000000000000000000000000000000000000..a2dbc4e2fa69afae2b8677f166ea72d8a9118f6a
--- /dev/null
+++ b/api/funkwhale_api/favorites/activities.py
@@ -0,0 +1,19 @@
+from funkwhale_api.common import channels
+from funkwhale_api.activity import record
+
+from . import serializers
+
+record.registry.register_serializer(
+    serializers.TrackFavoriteActivitySerializer)
+
+
+@record.registry.register_consumer('favorites.TrackFavorite')
+def broadcast_track_favorite_to_instance_activity(data, obj):
+    if obj.user.privacy_level not in ['instance', 'everyone']:
+        return
+
+    channels.group_send('instance_activity', {
+        'type': 'event.send',
+        'text': '',
+        'data': data
+    })
diff --git a/api/funkwhale_api/favorites/consumers.py b/api/funkwhale_api/favorites/consumers.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/favorites/models.py b/api/funkwhale_api/favorites/models.py
index 899ed9cff7f403bc2089e34cb3ff313ebaceff55..0c6a6b11c6e86083c63a49dfbdf45021554e380c 100644
--- a/api/funkwhale_api/favorites/models.py
+++ b/api/funkwhale_api/favorites/models.py
@@ -1,8 +1,10 @@
+from django.conf import settings
 from django.db import models
 from django.utils import timezone
 
 from funkwhale_api.music.models import Track
 
+
 class TrackFavorite(models.Model):
     creation_date = models.DateTimeField(default=timezone.now)
     user = models.ForeignKey(
@@ -18,3 +20,7 @@ class TrackFavorite(models.Model):
     def add(cls, track, user):
         favorite, created = cls.objects.get_or_create(user=user, track=track)
         return favorite
+
+    def get_activity_url(self):
+        return '{}/favorites/tracks/{}'.format(
+            self.user.get_activity_url(), self.pk)
diff --git a/api/funkwhale_api/favorites/serializers.py b/api/funkwhale_api/favorites/serializers.py
index 57af4570e0df7669bfce087433cdcab245495d5e..276b0f6bde6d19120998a21f58f44a5dd3ffbd01 100644
--- a/api/funkwhale_api/favorites/serializers.py
+++ b/api/funkwhale_api/favorites/serializers.py
@@ -1,10 +1,39 @@
+from django.conf import settings
+
 from rest_framework import serializers
 
+from funkwhale_api.activity import serializers as activity_serializers
 from funkwhale_api.music.serializers import TrackSerializerNested
+from funkwhale_api.music.serializers import TrackActivitySerializer
+from funkwhale_api.users.serializers import UserActivitySerializer
 
 from . import models
 
 
+class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
+    type = serializers.SerializerMethodField()
+    object = TrackActivitySerializer(source='track')
+    actor = UserActivitySerializer(source='user')
+    published = serializers.DateTimeField(source='creation_date')
+
+    class Meta:
+        model = models.TrackFavorite
+        fields = [
+            'id',
+            'local_id',
+            'object',
+            'type',
+            'actor',
+            'published'
+        ]
+
+    def get_actor(self, obj):
+        return UserActivitySerializer(obj.user).data
+
+    def get_type(self, obj):
+        return 'Like'
+
+
 class UserTrackFavoriteSerializer(serializers.ModelSerializer):
     # track = TrackSerializerNested(read_only=True)
     class Meta:
diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py
index 08ae00b684c30bcf5df91d5438dae1c03c790259..d874c9e1e626b9b30101088217336735d544a0e1 100644
--- a/api/funkwhale_api/favorites/views.py
+++ b/api/funkwhale_api/favorites/views.py
@@ -4,6 +4,7 @@ from rest_framework.response import Response
 from rest_framework import pagination
 from rest_framework.decorators import list_route
 
+from funkwhale_api.activity import record
 from funkwhale_api.music.models import Track
 from funkwhale_api.common.permissions import ConditionalAuthentication
 
@@ -33,6 +34,7 @@ class TrackFavoriteViewSet(mixins.CreateModelMixin,
         instance = self.perform_create(serializer)
         serializer = self.get_serializer(instance=instance)
         headers = self.get_success_headers(serializer.data)
+        record.send(instance)
         return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
 
     def get_queryset(self):
diff --git a/api/funkwhale_api/history/activities.py b/api/funkwhale_api/history/activities.py
new file mode 100644
index 0000000000000000000000000000000000000000..e478f9b7f677a4d4e524e343e84b0a08420bbcb3
--- /dev/null
+++ b/api/funkwhale_api/history/activities.py
@@ -0,0 +1,19 @@
+from funkwhale_api.common import channels
+from funkwhale_api.activity import record
+
+from . import serializers
+
+record.registry.register_serializer(
+    serializers.ListeningActivitySerializer)
+
+
+@record.registry.register_consumer('history.Listening')
+def broadcast_listening_to_instance_activity(data, obj):
+    if obj.user.privacy_level not in ['instance', 'everyone']:
+        return
+
+    channels.group_send('instance_activity', {
+        'type': 'event.send',
+        'text': '',
+        'data': data
+    })
diff --git a/api/funkwhale_api/history/models.py b/api/funkwhale_api/history/models.py
index f7f62de62a2f59a08edd9f0a9be28bed65c102fb..56310ddc0d2546784bed02952fe6142a2d139858 100644
--- a/api/funkwhale_api/history/models.py
+++ b/api/funkwhale_api/history/models.py
@@ -25,3 +25,8 @@ class Listening(models.Model):
             raise ValidationError('Cannot have both session_key and user empty for listening')
 
         super().save(**kwargs)
+
+
+    def get_activity_url(self):
+        return '{}/listenings/tracks/{}'.format(
+            self.user.get_activity_url(), self.pk)
diff --git a/api/funkwhale_api/history/serializers.py b/api/funkwhale_api/history/serializers.py
index 64bdf41c67164b3e8cd18551be557771909845f2..7a2280cea2a236357982f309b7218c3e0d073299 100644
--- a/api/funkwhale_api/history/serializers.py
+++ b/api/funkwhale_api/history/serializers.py
@@ -1,9 +1,37 @@
 from rest_framework import serializers
 
+from funkwhale_api.activity import serializers as activity_serializers
 from funkwhale_api.music.serializers import TrackSerializerNested
+from funkwhale_api.music.serializers import TrackActivitySerializer
+from funkwhale_api.users.serializers import UserActivitySerializer
+
 from . import models
 
 
+class ListeningActivitySerializer(activity_serializers.ModelSerializer):
+    type = serializers.SerializerMethodField()
+    object = TrackActivitySerializer(source='track')
+    actor = UserActivitySerializer(source='user')
+    published = serializers.DateTimeField(source='end_date')
+
+    class Meta:
+        model = models.Listening
+        fields = [
+            'id',
+            'local_id',
+            'object',
+            'type',
+            'actor',
+            'published'
+        ]
+
+    def get_actor(self, obj):
+        return UserActivitySerializer(obj.user).data
+
+    def get_type(self, obj):
+        return 'Listen'
+
+
 class ListeningSerializer(serializers.ModelSerializer):
 
     class Meta:
diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py
index 59dcbd26b8449abef324273219a961d44f39f489..d5cbe316ba88b455755cdbaf843086f7c99c09f3 100644
--- a/api/funkwhale_api/history/views.py
+++ b/api/funkwhale_api/history/views.py
@@ -3,8 +3,9 @@ from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.decorators import detail_route
 
-from funkwhale_api.music.serializers import TrackSerializerNested
+from funkwhale_api.activity import record
 from funkwhale_api.common.permissions import ConditionalAuthentication
+from funkwhale_api.music.serializers import TrackSerializerNested
 
 from . import models
 from . import serializers
@@ -17,6 +18,12 @@ class ListeningViewSet(mixins.CreateModelMixin,
     queryset = models.Listening.objects.all()
     permission_classes = [ConditionalAuthentication]
 
+    def perform_create(self, serializer):
+        r = super().perform_create(serializer)
+        if self.request.user.is_authenticated:
+            record.send(serializer.instance)
+        return r
+
     def get_queryset(self):
         queryset = super().get_queryset()
         if self.request.user.is_authenticated:
diff --git a/api/funkwhale_api/instance/consumers.py b/api/funkwhale_api/instance/consumers.py
new file mode 100644
index 0000000000000000000000000000000000000000..eee5f7f0e38641bfd2d9c16fce49c8cd10d8c8a9
--- /dev/null
+++ b/api/funkwhale_api/instance/consumers.py
@@ -0,0 +1,8 @@
+from funkwhale_api.common.consumers import JsonAuthConsumer
+
+
+class InstanceActivityConsumer(JsonAuthConsumer):
+    groups = ["instance_activity"]
+
+    def event_send(self, message):
+        self.send_json(message['data'])
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index 97992fc8f12cafa7af7cb99c7cbfb1cee2b42192..7138dcdd6d51f52568649d06fc34eb655d64e4d0 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -360,6 +360,12 @@ class Track(APIModelMixin):
                 self.title,
             )
 
+    def get_activity_url(self):
+        if self.mbid:
+            return 'https://musicbrainz.org/recording/{}'.format(
+                self.mbid)
+        return settings.FUNKWHALE_URL + '/tracks/{}'.format(self.pk)
+
 
 class TrackFile(models.Model):
     track = models.ForeignKey(
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index db6298a9e446eddc64bc17f95e3a6b75bf126573..48419bbe45675aad4e6ad271e9be420219f7d671 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -1,6 +1,8 @@
 from rest_framework import serializers
 from taggit.models import Tag
 
+from funkwhale_api.activity import serializers as activity_serializers
+
 from . import models
 
 
@@ -127,3 +129,24 @@ class ImportBatchSerializer(serializers.ModelSerializer):
         model = models.ImportBatch
         fields = ('id', 'jobs', 'status', 'creation_date', 'import_request')
         read_only_fields = ('creation_date',)
+
+
+class TrackActivitySerializer(activity_serializers.ModelSerializer):
+    type = serializers.SerializerMethodField()
+    name = serializers.CharField(source='title')
+    artist = serializers.CharField(source='artist.name')
+    album = serializers.CharField(source='album.title')
+
+    class Meta:
+        model = models.Track
+        fields = [
+            'id',
+            'local_id',
+            'name',
+            'type',
+            'artist',
+            'album',
+        ]
+
+    def get_type(self, obj):
+        return 'Audio'
diff --git a/api/funkwhale_api/users/migrations/0004_user_privacy_level.py b/api/funkwhale_api/users/migrations/0004_user_privacy_level.py
new file mode 100644
index 0000000000000000000000000000000000000000..81891eb0f0abc85657e6e16ec185c697c257cfe5
--- /dev/null
+++ b/api/funkwhale_api/users/migrations/0004_user_privacy_level.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.2 on 2018-03-01 19:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0003_auto_20171226_1357'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='user',
+            name='privacy_level',
+            field=models.CharField(choices=[('me', 'Only me'), ('followers', 'Me and my followers'), ('instance', 'Everyone on my instance, and my followers'), ('everyone', 'Everyone, including people on other instances')], default='instance', max_length=30),
+        ),
+    ]
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 3a0baf11a30c73397b2c31d14fe1ec29d9557a7c..a5478656b178c94037ca232834cc15d36c6b1f9d 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -3,6 +3,7 @@ from __future__ import unicode_literals, absolute_import
 
 import uuid
 
+from django.conf import settings
 from django.contrib.auth.models import AbstractUser
 from django.urls import reverse
 from django.db import models
@@ -10,6 +11,14 @@ from django.utils.encoding import python_2_unicode_compatible
 from django.utils.translation import ugettext_lazy as _
 
 
+
+PRIVACY_LEVEL_CHOICES = [
+    ('me', 'Only me'),
+    ('followers', 'Me and my followers'),
+    ('instance', 'Everyone on my instance, and my followers'),
+    ('everyone', 'Everyone, including people on other instances'),
+]
+
 @python_2_unicode_compatible
 class User(AbstractUser):
 
@@ -30,6 +39,9 @@ class User(AbstractUser):
         },
     }
 
+    privacy_level = models.CharField(
+        max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance')
+
     def __str__(self):
         return self.username
 
@@ -43,3 +55,6 @@ class User(AbstractUser):
     def set_password(self, raw_password):
         super().set_password(raw_password)
         self.update_secret_key()
+
+    def get_activity_url(self):
+        return settings.FUNKWHALE_URL + '/@{}'.format(self.username)
diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py
index 8c218b1c28acd2811b208e0a3f4b4f0d788c1aea..b21aa69355b2ca4acea883b52e9401055382b6b3 100644
--- a/api/funkwhale_api/users/serializers.py
+++ b/api/funkwhale_api/users/serializers.py
@@ -1,15 +1,44 @@
 from rest_framework import serializers
 
+from funkwhale_api.activity import serializers as activity_serializers
+
 from . import models
 
 
+class UserActivitySerializer(activity_serializers.ModelSerializer):
+    type = serializers.SerializerMethodField()
+    name = serializers.CharField(source='username')
+    local_id = serializers.CharField(source='username')
+
+    class Meta:
+        model = models.User
+        fields = [
+            'id',
+            'local_id',
+            'name',
+            'type'
+        ]
+
+    def get_type(self, obj):
+        return 'Person'
+
+
 class UserBasicSerializer(serializers.ModelSerializer):
     class Meta:
         model = models.User
         fields = ['id', 'username', 'name', 'date_joined']
 
 
-class UserSerializer(serializers.ModelSerializer):
+class UserWriteSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = models.User
+        fields = [
+            'name',
+            'privacy_level'
+        ]
+
+
+class UserReadSerializer(serializers.ModelSerializer):
 
     permissions = serializers.SerializerMethodField()
 
@@ -24,6 +53,7 @@ class UserSerializer(serializers.ModelSerializer):
             'is_superuser',
             'permissions',
             'date_joined',
+            'privacy_level'
         ]
 
     def get_permissions(self, o):
diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py
index b7c1df28f9dba34739756c7c4b666b8cdca88929..7c58363a3ed7fcbdccdd86138d2081a17b564631 100644
--- a/api/funkwhale_api/users/views.py
+++ b/api/funkwhale_api/users/views.py
@@ -1,4 +1,5 @@
 from rest_framework.response import Response
+from rest_framework import mixins
 from rest_framework import viewsets
 from rest_framework.decorators import list_route
 
@@ -23,12 +24,25 @@ class RegisterView(BaseRegisterView):
         return get_adapter().is_open_for_signup(request)
 
 
-class UserViewSet(viewsets.GenericViewSet):
+class UserViewSet(
+        mixins.UpdateModelMixin,
+        viewsets.GenericViewSet):
     queryset = models.User.objects.all()
-    serializer_class = serializers.UserSerializer
+    serializer_class = serializers.UserWriteSerializer
+    lookup_field = 'username'
 
     @list_route(methods=['get'])
     def me(self, request, *args, **kwargs):
         """Return information about the current user"""
-        serializer = self.serializer_class(request.user)
+        serializer = serializers.UserReadSerializer(request.user)
         return Response(serializer.data)
+
+    def update(self, request, *args, **kwargs):
+        if not self.request.user.username == kwargs.get('username'):
+            return Response(status=403)
+        return super().update(request, *args, **kwargs)
+
+    def partial_update(self, request, *args, **kwargs):
+        if not self.request.user.username == kwargs.get('username'):
+            return Response(status=403)
+        return super().partial_update(request, *args, **kwargs)
diff --git a/api/requirements.apt b/api/requirements.apt
index 462a5a705c75b319636449490c6e3c29b089a7f1..224ff955ae45daaf7677d9297bf7c0ee8aba3a84 100644
--- a/api/requirements.apt
+++ b/api/requirements.apt
@@ -1,11 +1,8 @@
 build-essential
-gettext
-zlib1g-dev
+curl
+ffmpeg
 libjpeg-dev
-zlib1g-dev
+libmagic-dev
 libpq-dev
 postgresql-client
-libmagic-dev
-ffmpeg
 python3-dev
-curl
diff --git a/api/requirements/base.txt b/api/requirements/base.txt
index 133fcc0cb65f0deb430d73ff5259d05efc215cc2..d402d359137126ae87bb255aa091ca548a61a681 100644
--- a/api/requirements/base.txt
+++ b/api/requirements/base.txt
@@ -59,3 +59,5 @@ pyacoustid>=1.1.5,<1.2
 raven>=6.5,<7
 python-magic==0.4.15
 ffmpeg-python==0.1.10
+channels>=2,<2.1
+channels_redis>=2.1,<2.2
diff --git a/api/requirements/production.txt b/api/requirements/production.txt
index 42b66eb1535792ae0bb5a8407c0c140df1b90049..4ad8edf940dc4524f77c6ac9b339e55c7b1c5a7c 100644
--- a/api/requirements/production.txt
+++ b/api/requirements/production.txt
@@ -4,7 +4,4 @@
 # WSGI Handler
 # ------------------------------------------------
 
-# there's no python 3 support in stable, have to use the latest release candidate for gevent
-gevent==1.1rc1
-
-gunicorn==19.4.1
+daphne==2.0.4
diff --git a/api/test.yml b/api/test.yml
index e892dfb178221ccd5ba8ed83558f3a6436b20dad..5e785cb1ac3e20eba418a5d89673a207a84519de 100644
--- a/api/test.yml
+++ b/api/test.yml
@@ -12,5 +12,6 @@ services:
     environment:
       - "DJANGO_ALLOWED_HOSTS=localhost"
       - "DATABASE_URL=postgresql://postgres@postgres/postgres"
+      - "FUNKWHALE_URL=https://funkwhale.test"
   postgres:
     image: postgres
diff --git a/api/tests/activity/test_record.py b/api/tests/activity/test_record.py
new file mode 100644
index 0000000000000000000000000000000000000000..41846ba6f109cdc94b24c4e1ab01fb9812065310
--- /dev/null
+++ b/api/tests/activity/test_record.py
@@ -0,0 +1,45 @@
+import pytest
+
+from django.db import models
+from rest_framework import serializers
+
+from funkwhale_api.activity import record
+
+
+class FakeModel(models.Model):
+    class Meta:
+        app_label = 'tests'
+
+
+class FakeSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = FakeModel
+        fields = ['id']
+
+
+
+
+def test_can_bind_serializer_to_model(activity_registry):
+    activity_registry.register_serializer(FakeSerializer)
+
+    assert activity_registry['tests.FakeModel']['serializer'] == FakeSerializer
+
+
+def test_can_bind_consumer_to_model(activity_registry):
+    activity_registry.register_serializer(FakeSerializer)
+    @activity_registry.register_consumer('tests.FakeModel')
+    def propagate(data, obj):
+        return True
+
+    assert activity_registry['tests.FakeModel']['consumers'] == [propagate]
+
+
+def test_record_object_calls_consumer(activity_registry, mocker):
+    activity_registry.register_serializer(FakeSerializer)
+    stub = mocker.stub()
+    activity_registry.register_consumer('tests.FakeModel')(stub)
+    o = FakeModel(id=1)
+    data = FakeSerializer(o).data
+    record.send(o)
+
+    stub.assert_called_once_with(data=data, obj=o)
diff --git a/api/tests/channels/test_auth.py b/api/tests/channels/test_auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..a2b7eaf0ca685c1c197ed339a5963aeceea215c2
--- /dev/null
+++ b/api/tests/channels/test_auth.py
@@ -0,0 +1,37 @@
+import pytest
+
+from rest_framework_jwt.settings import api_settings
+
+from funkwhale_api.common.auth import TokenAuthMiddleware
+
+jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
+jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
+
+
+@pytest.mark.parametrize('query_string', [
+    b'token=wrong',
+    b'',
+])
+def test_header_anonymous(query_string, factories):
+    def callback(scope):
+        assert scope['user'].is_anonymous
+
+    scope = {
+        'query_string': query_string
+    }
+    consumer = TokenAuthMiddleware(callback)
+    consumer(scope)
+
+
+def test_header_correct_token(factories):
+    user = factories['users.User']()
+    payload = jwt_payload_handler(user)
+    token = jwt_encode_handler(payload)
+    def callback(scope):
+        assert scope['user'] == user
+
+    scope = {
+        'query_string': 'token={}'.format(token).encode('utf-8')
+    }
+    consumer = TokenAuthMiddleware(callback)
+    consumer(scope)
diff --git a/api/tests/channels/test_consumers.py b/api/tests/channels/test_consumers.py
new file mode 100644
index 0000000000000000000000000000000000000000..f1648efb3a614fb2f74b8e941083f99491043dd0
--- /dev/null
+++ b/api/tests/channels/test_consumers.py
@@ -0,0 +1,26 @@
+from funkwhale_api.common import consumers
+
+
+def test_auth_consumer_requires_valid_user(mocker):
+    m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.close')
+    scope = {'user': None}
+    consumer = consumers.JsonAuthConsumer(scope=scope)
+    consumer.connect()
+    m.assert_called_once_with()
+
+
+def test_auth_consumer_requires_user_in_scope(mocker):
+    m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.close')
+    scope = {}
+    consumer = consumers.JsonAuthConsumer(scope=scope)
+    consumer.connect()
+    m.assert_called_once_with()
+
+
+def test_auth_consumer_accepts_connection(mocker, factories):
+    user = factories['users.User']()
+    m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.accept')
+    scope = {'user': user}
+    consumer = consumers.JsonAuthConsumer(scope=scope)
+    consumer.connect()
+    m.assert_called_once_with()
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index 10d7c323512c684ff495bda2a2a7a7ae581213f8..2d655f23f28dd2ea1ad56d4073df1147451c603c 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -5,6 +5,7 @@ from django.core.cache import cache as django_cache
 from dynamic_preferences.registries import global_preferences_registry
 from rest_framework.test import APIClient
 
+from funkwhale_api.activity import record
 from funkwhale_api.taskapp import celery
 
 
@@ -81,3 +82,28 @@ def superuser_client(db, factories, client):
     setattr(client, 'user', user)
     yield client
     delattr(client, 'user')
+
+
+@pytest.fixture
+def activity_registry():
+    r = record.registry
+    state = list(record.registry.items())
+    yield record.registry
+    record.registry.clear()
+    for key, value in state:
+        record.registry[key] = value
+
+
+@pytest.fixture
+def activity_registry():
+    r = record.registry
+    state = list(record.registry.items())
+    yield record.registry
+    record.registry.clear()
+    for key, value in state:
+        record.registry[key] = value
+
+
+@pytest.fixture
+def activity_muted(activity_registry, mocker):
+    yield mocker.patch.object(record, 'send')
diff --git a/api/tests/favorites/test_activity.py b/api/tests/favorites/test_activity.py
new file mode 100644
index 0000000000000000000000000000000000000000..63174f9e2693a3fb0923c1aa0c1b682a06b2226a
--- /dev/null
+++ b/api/tests/favorites/test_activity.py
@@ -0,0 +1,75 @@
+from funkwhale_api.users.serializers import UserActivitySerializer
+from funkwhale_api.music.serializers import TrackActivitySerializer
+from funkwhale_api.favorites import serializers
+from funkwhale_api.favorites import activities
+
+
+def test_get_favorite_activity_url(settings, factories):
+    favorite = factories['favorites.TrackFavorite']()
+    user_url = favorite.user.get_activity_url()
+    expected = '{}/favorites/tracks/{}'.format(
+        user_url, favorite.pk)
+    assert favorite.get_activity_url() == expected
+
+
+def test_activity_favorite_serializer(factories):
+    favorite = factories['favorites.TrackFavorite']()
+
+    actor = UserActivitySerializer(favorite.user).data
+    field = serializers.serializers.DateTimeField()
+    expected = {
+        "type": "Like",
+        "local_id": favorite.pk,
+        "id": favorite.get_activity_url(),
+        "actor": actor,
+        "object": TrackActivitySerializer(favorite.track).data,
+        "published": field.to_representation(favorite.creation_date),
+    }
+
+    data = serializers.TrackFavoriteActivitySerializer(favorite).data
+
+    assert data == expected
+
+
+def test_track_favorite_serializer_is_connected(activity_registry):
+    conf = activity_registry['favorites.TrackFavorite']
+    assert conf['serializer'] == serializers.TrackFavoriteActivitySerializer
+
+
+def test_track_favorite_serializer_instance_activity_consumer(
+        activity_registry):
+    conf = activity_registry['favorites.TrackFavorite']
+    consumer = activities.broadcast_track_favorite_to_instance_activity
+    assert consumer in conf['consumers']
+
+
+def test_broadcast_track_favorite_to_instance_activity(
+        factories, mocker):
+    p = mocker.patch('funkwhale_api.common.channels.group_send')
+    favorite = factories['favorites.TrackFavorite']()
+    data = serializers.TrackFavoriteActivitySerializer(favorite).data
+    consumer = activities.broadcast_track_favorite_to_instance_activity
+    message = {
+        "type": 'event.send',
+        "text": '',
+        "data": data
+    }
+    consumer(data=data, obj=favorite)
+    p.assert_called_once_with('instance_activity', message)
+
+
+def test_broadcast_track_favorite_to_instance_activity_private(
+        factories, mocker):
+    p = mocker.patch('funkwhale_api.common.channels.group_send')
+    favorite = factories['favorites.TrackFavorite'](
+        user__privacy_level='me'
+    )
+    data = serializers.TrackFavoriteActivitySerializer(favorite).data
+    consumer = activities.broadcast_track_favorite_to_instance_activity
+    message = {
+        "type": 'event.send',
+        "text": '',
+        "data": data
+    }
+    consumer(data=data, obj=favorite)
+    p.assert_not_called()
diff --git a/api/tests/test_favorites.py b/api/tests/favorites/test_favorites.py
similarity index 79%
rename from api/tests/test_favorites.py
rename to api/tests/favorites/test_favorites.py
index 8165722eacc969a9ea651dd70815d3fa1d9c75ea..f4a045af825e9431f308903569ac16b07d609692 100644
--- a/api/tests/test_favorites.py
+++ b/api/tests/favorites/test_favorites.py
@@ -33,7 +33,8 @@ def test_user_can_get_his_favorites(factories, logged_in_client, client):
     assert expected == parsed_json['results']
 
 
-def test_user_can_add_favorite_via_api(factories, logged_in_client, client):
+def test_user_can_add_favorite_via_api(
+        factories, logged_in_client, activity_muted):
     track = factories['music.Track']()
     url = reverse('api:v1:favorites:tracks-list')
     response = logged_in_client.post(url, {'track': track.pk})
@@ -51,6 +52,27 @@ def test_user_can_add_favorite_via_api(factories, logged_in_client, client):
     assert favorite.user == logged_in_client.user
 
 
+def test_adding_favorites_calls_activity_record(
+        factories, logged_in_client, activity_muted):
+    track = factories['music.Track']()
+    url = reverse('api:v1:favorites:tracks-list')
+    response = logged_in_client.post(url, {'track': track.pk})
+
+    favorite = TrackFavorite.objects.latest('id')
+    expected = {
+        'track': track.pk,
+        'id': favorite.id,
+        'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'),
+    }
+    parsed_json = json.loads(response.content.decode('utf-8'))
+
+    assert expected == parsed_json
+    assert favorite.track == track
+    assert favorite.user == logged_in_client.user
+
+    activity_muted.assert_called_once_with(favorite)
+
+
 def test_user_can_remove_favorite_via_api(logged_in_client, factories, client):
     favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
     url = reverse('api:v1:favorites:tracks-detail', kwargs={'pk': favorite.pk})
diff --git a/api/tests/history/__init__.py b/api/tests/history/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/tests/history/test_activity.py b/api/tests/history/test_activity.py
new file mode 100644
index 0000000000000000000000000000000000000000..b5ab07b8235f12045f5ee1b9fba3fce0c3da57d5
--- /dev/null
+++ b/api/tests/history/test_activity.py
@@ -0,0 +1,75 @@
+from funkwhale_api.users.serializers import UserActivitySerializer
+from funkwhale_api.music.serializers import TrackActivitySerializer
+from funkwhale_api.history import serializers
+from funkwhale_api.history import activities
+
+
+def test_get_listening_activity_url(settings, factories):
+    listening = factories['history.Listening']()
+    user_url = listening.user.get_activity_url()
+    expected = '{}/listenings/tracks/{}'.format(
+        user_url, listening.pk)
+    assert listening.get_activity_url() == expected
+
+
+def test_activity_listening_serializer(factories):
+    listening = factories['history.Listening']()
+
+    actor = UserActivitySerializer(listening.user).data
+    field = serializers.serializers.DateTimeField()
+    expected = {
+        "type": "Listen",
+        "local_id": listening.pk,
+        "id": listening.get_activity_url(),
+        "actor": actor,
+        "object": TrackActivitySerializer(listening.track).data,
+        "published": field.to_representation(listening.end_date),
+    }
+
+    data = serializers.ListeningActivitySerializer(listening).data
+
+    assert data == expected
+
+
+def test_track_listening_serializer_is_connected(activity_registry):
+    conf = activity_registry['history.Listening']
+    assert conf['serializer'] == serializers.ListeningActivitySerializer
+
+
+def test_track_listening_serializer_instance_activity_consumer(
+        activity_registry):
+    conf = activity_registry['history.Listening']
+    consumer = activities.broadcast_listening_to_instance_activity
+    assert consumer in conf['consumers']
+
+
+def test_broadcast_listening_to_instance_activity(
+        factories, mocker):
+    p = mocker.patch('funkwhale_api.common.channels.group_send')
+    listening = factories['history.Listening']()
+    data = serializers.ListeningActivitySerializer(listening).data
+    consumer = activities.broadcast_listening_to_instance_activity
+    message = {
+        "type": 'event.send',
+        "text": '',
+        "data": data
+    }
+    consumer(data=data, obj=listening)
+    p.assert_called_once_with('instance_activity', message)
+
+
+def test_broadcast_listening_to_instance_activity_private(
+        factories, mocker):
+    p = mocker.patch('funkwhale_api.common.channels.group_send')
+    listening = factories['history.Listening'](
+        user__privacy_level='me'
+    )
+    data = serializers.ListeningActivitySerializer(listening).data
+    consumer = activities.broadcast_listening_to_instance_activity
+    message = {
+        "type": 'event.send',
+        "text": '',
+        "data": data
+    }
+    consumer(data=data, obj=listening)
+    p.assert_not_called()
diff --git a/api/tests/test_history.py b/api/tests/history/test_history.py
similarity index 70%
rename from api/tests/test_history.py
rename to api/tests/history/test_history.py
index 113e5ff6405f60665305f04b379997e8b9343d9c..ec8689e9637ca2686452e72d9628a581251e014f 100644
--- a/api/tests/test_history.py
+++ b/api/tests/history/test_history.py
@@ -28,7 +28,8 @@ def test_anonymous_user_can_create_listening_via_api(client, factories, settings
     assert listening.session_key == client.session.session_key
 
 
-def test_logged_in_user_can_create_listening_via_api(logged_in_client, factories):
+def test_logged_in_user_can_create_listening_via_api(
+        logged_in_client, factories, activity_muted):
     track = factories['music.Track']()
 
     url = reverse('api:v1:history:listenings-list')
@@ -40,3 +41,17 @@ def test_logged_in_user_can_create_listening_via_api(logged_in_client, factories
 
     assert listening.track == track
     assert listening.user == logged_in_client.user
+
+
+def test_adding_listening_calls_activity_record(
+        factories, logged_in_client, activity_muted):
+    track = factories['music.Track']()
+
+    url = reverse('api:v1:history:listenings-list')
+    response = logged_in_client.post(url, {
+        'track': track.pk,
+    })
+
+    listening = models.Listening.objects.latest('id')
+
+    activity_muted.assert_called_once_with(listening)
diff --git a/api/tests/music/test_activity.py b/api/tests/music/test_activity.py
new file mode 100644
index 0000000000000000000000000000000000000000..f604874c14e75442b9ba50170107a4aa127497a2
--- /dev/null
+++ b/api/tests/music/test_activity.py
@@ -0,0 +1,17 @@
+from funkwhale_api.users.serializers import UserActivitySerializer
+from funkwhale_api.favorites import serializers
+
+
+
+def test_get_track_activity_url_mbid(factories):
+    track = factories['music.Track']()
+    expected = 'https://musicbrainz.org/recording/{}'.format(
+        track.mbid)
+    assert track.get_activity_url() == expected
+
+
+def test_get_track_activity_url_no_mbid(settings, factories):
+    track = factories['music.Track'](mbid=None)
+    expected = settings.FUNKWHALE_URL + '/tracks/{}'.format(
+        track.pk)
+    assert track.get_activity_url() == expected
diff --git a/api/tests/requests/__init__.py b/api/tests/requests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/tests/users/test_activity.py b/api/tests/users/test_activity.py
new file mode 100644
index 0000000000000000000000000000000000000000..26d0b11f8ab42ff7644e688a92e4693f755ff054
--- /dev/null
+++ b/api/tests/users/test_activity.py
@@ -0,0 +1,22 @@
+from funkwhale_api.users import serializers
+
+
+def test_get_user_activity_url(settings, factories):
+    user = factories['users.User']()
+    assert user.get_activity_url() == '{}/@{}'.format(
+        settings.FUNKWHALE_URL, user.username)
+
+
+def test_activity_user_serializer(factories):
+    user = factories['users.User']()
+
+    expected = {
+        "type": "Person",
+        "id": user.get_activity_url(),
+        "local_id": user.username,
+        "name": user.username,
+    }
+
+    data = serializers.UserActivitySerializer(user).data
+
+    assert data == expected
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index 569acbd15ee5138150dd7de4112cd7ebd2d5523a..02b903bf41ea6bb9169987bd5ed1d147aff2122e 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -1,4 +1,5 @@
 import json
+import pytest
 
 from django.test import RequestFactory
 from django.urls import reverse
@@ -116,3 +117,37 @@ def test_changing_password_updates_secret_key(logged_in_client):
 
     assert user.secret_key != secret_key
     assert user.password != password
+
+
+def test_user_can_patch_his_own_settings(logged_in_api_client):
+    user = logged_in_api_client.user
+    payload = {
+        'privacy_level': 'me',
+    }
+    url = reverse(
+        'api:v1:users:users-detail',
+        kwargs={'username': user.username})
+
+    response = logged_in_api_client.patch(url, payload)
+
+    assert response.status_code == 200
+    user.refresh_from_db()
+
+    assert user.privacy_level == 'me'
+
+
+@pytest.mark.parametrize('method', ['put', 'patch'])
+def test_user_cannot_patch_another_user(
+        method, logged_in_api_client, factories):
+    user = factories['users.User']()
+    payload = {
+        'privacy_level': 'me',
+    }
+    url = reverse(
+        'api:v1:users:users-detail',
+        kwargs={'username': user.username})
+
+    handler = getattr(logged_in_api_client, method)
+    response = handler(url, payload)
+
+    assert response.status_code == 403
diff --git a/changes/changelog.d/23.feature b/changes/changelog.d/23.feature
new file mode 100644
index 0000000000000000000000000000000000000000..fbfac0d919543d2bd0bfa05ce4c66f1038bc6509
--- /dev/null
+++ b/changes/changelog.d/23.feature
@@ -0,0 +1,16 @@
+Basic activity stream for listening and favorites (#23)
+
+A new "Activity" page is now available from the sidebar, where you can
+browse your instance activity. At the moment, this includes other users
+favorites and listening, but more activity types will be implemented in the
+future.
+
+Internally, we implemented those events by following the Activity Stream
+specification, which will help us to be compatible with other networks
+in the long-term.
+
+A new settings page has been added to control the visibility of your activity.
+By default, your activity will be browsable by anyone on your instance,
+but you can switch to a full private mode where nothing is shared.
+
+The setting form is available in your profile.
diff --git a/changes/changelog.d/34.feature b/changes/changelog.d/34.feature
new file mode 100644
index 0000000000000000000000000000000000000000..1403f3dc149cc3e71abba871f6207ab765f3b2d0
--- /dev/null
+++ b/changes/changelog.d/34.feature
@@ -0,0 +1,59 @@
+Switched to django-channels and daphne for serving HTTP and websocket (#34)
+
+Upgrade notes
+^^^^^^^^^^^^^
+
+This release include an important change in the way we serve the HTTP API.
+To prepare for new realtime features and enable websocket support in Funkwhale,
+we are now using django-channels and daphne to serve HTTP and websocket traffic.
+
+This replaces gunicorn and the switch should be easy assuming you
+follow the upgrade process described bellow.
+
+If you are using docker, please remove the command instruction inside the
+api service, as the up-to-date command is now included directly in the image
+as the default entry point:
+
+.. code-block:: yaml
+
+    api:
+      restart: unless-stopped
+      image: funkwhale/funkwhale:${FUNKWHALE_VERSION:-latest}
+      command: ./compose/django/gunicorn.sh  # You can remove this line
+
+On non docker setups, you'll have to update the [Service] block of your
+funkwhale-server systemd unit file to launch the application server using daphne instead of gunicorn.
+
+The new configuration should be similar to this:
+
+.. code-block:: ini
+
+    [Service]
+    User=funkwhale
+    # adapt this depending on the path of your funkwhale installation
+    WorkingDirectory=/srv/funkwhale/api
+    EnvironmentFile=/srv/funkwhale/config/.env
+    ExecStart=/usr/local/bin/daphne -b ${FUNKWHALE_API_IP} -p ${FUNKWHALE_API_PORT} config.asgi:application
+
+Ensure you update funkwhale's dependencies as usual to install the required
+packages.
+
+On both docker and non-docker setup, you'll also have to update your nginx
+configuration for websocket support. Ensure you have the following blocks
+included in your virtualhost file:
+
+.. code-block:: txt
+
+    map $http_upgrade $connection_upgrade {
+        default upgrade;
+        ''      close;
+    }
+
+    server {
+        ...
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection $connection_upgrade;
+    }
+
+Remember to reload your nginx server after the edit.
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 0c3b565474355632257694bfe84d729a376301da..e6292812e0470156da0f131e735e9ab64c2a9c17 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -43,7 +43,6 @@ services:
     restart: unless-stopped
     image: funkwhale/funkwhale:${FUNKWHALE_VERSION:-latest}
     env_file: .env
-    command: ./compose/django/gunicorn.sh
     volumes:
       - ./data/music:/music:ro
       - ./data/media:/app/funkwhale_api/media
diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample
index 6a4b15b67cf04857b87296c30a23060e650f2d7c..037dc4651aae2cace9375b34c563691d572cd984 100644
--- a/deploy/env.prod.sample
+++ b/deploy/env.prod.sample
@@ -2,6 +2,7 @@
 # following variables:
 # - DJANGO_SECRET_KEY
 # - DJANGO_ALLOWED_HOSTS
+# - FUNKWHALE_URL
 
 # Additionaly, on non-docker setup, you'll also have to tweak/uncomment those
 # variables:
@@ -28,6 +29,9 @@ FUNKWHALE_VERSION=latest
 FUNKWHALE_API_IP=127.0.0.1
 FUNKWHALE_API_PORT=5000
 
+# Replace this by the definitive, public domain you will use for
+# your instance
+FUNKWHALE_URL=https.//yourdomain.funwhale
 
 # API/Django configuration
 
diff --git a/deploy/funkwhale-server.service b/deploy/funkwhale-server.service
index 7ef6e389748bac6c972fbd0644412ceb375cb8e5..0027a80ab8e73802f9efa3e2b4febf792052b1bc 100644
--- a/deploy/funkwhale-server.service
+++ b/deploy/funkwhale-server.service
@@ -8,7 +8,7 @@ User=funkwhale
 # adapt this depending on the path of your funkwhale installation
 WorkingDirectory=/srv/funkwhale/api
 EnvironmentFile=/srv/funkwhale/config/.env
-ExecStart=/srv/funkwhale/virtualenv/bin/gunicorn config.wsgi:application -b ${FUNKWHALE_API_IP}:${FUNKWHALE_API_PORT}
+ExecStart=/usr/local/bin/daphne -b ${FUNKWHALE_API_IP} -p ${FUNKWHALE_API_PORT} config.asgi:application
 
 [Install]
 WantedBy=multi-user.target
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
index cf3b34056bc463e56ff9f306c333a908fdca6f60..125fbc6d4190b98e35032b98b0e7ad0c07644892 100644
--- a/deploy/nginx.conf
+++ b/deploy/nginx.conf
@@ -19,6 +19,12 @@ server {
     location / { return 301 https://$host$request_uri; }
 }
 
+# required for websocket support
+map $http_upgrade $connection_upgrade {
+    default upgrade;
+    ''      close;
+}
+
 server {
     listen      443 ssl http2;
     listen [::]:443 ssl http2;
@@ -51,6 +57,11 @@ server {
     proxy_set_header X-Forwarded-Port   $server_port;
     proxy_redirect off;
 
+    # websocket support
+    proxy_http_version 1.1;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection $connection_upgrade;
+
     location / {
         try_files $uri $uri/ @rewrites;
     }
diff --git a/dev.yml b/dev.yml
index e3cd50da7ab29b8de22783ed686313dc79dac6cd..2c102f3aea33e790f292ac1ec9755648690cc584 100644
--- a/dev.yml
+++ b/dev.yml
@@ -36,6 +36,7 @@ services:
       - C_FORCE_ROOT=true
       - "DATABASE_URL=postgresql://postgres@postgres/postgres"
       - "CACHE_URL=redis://redis:6379/0"
+      - "FUNKWHALE_URL=http://funkwhale.test"
     volumes:
       - ./api:/app
       - ./data/music:/music
@@ -54,6 +55,7 @@ services:
       - "DJANGO_SECRET_KEY=dev"
       - "DATABASE_URL=postgresql://postgres@postgres/postgres"
       - "CACHE_URL=redis://redis:6379/0"
+      - "FUNKWHALE_URL=http://funkwhale.test"
     links:
       - postgres
       - redis
diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev
index 29c04fc6643c17a8918c9ad4546993d49587336e..9847c2dcbcc71bf3040802d6289c7336ff40c340 100644
--- a/docker/nginx/conf.dev
+++ b/docker/nginx/conf.dev
@@ -28,6 +28,11 @@ http {
     #gzip  on;
     proxy_cache_path /tmp/funkwhale-transcode levels=1:2 keys_zone=transcode:10m max_size=1g inactive=24h use_temp_path=off;
 
+    map $http_upgrade $connection_upgrade {
+        default upgrade;
+        ''      close;
+    }
+
     server {
         listen 6001;
         charset     utf-8;
@@ -40,6 +45,9 @@ http {
         proxy_set_header X-Forwarded-Proto $scheme;
         proxy_set_header X-Forwarded-Host   localhost:8080;
         proxy_set_header X-Forwarded-Port   8080;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection $connection_upgrade;
         proxy_redirect off;
 
         location /_protected/media {
diff --git a/front/Dockerfile b/front/Dockerfile
index cdf92446b1c5fde91d3e8511ba5dec9e1743527c..60b03c9b8e3c9ff54cf7dee5f7e4e96273a749b6 100644
--- a/front/Dockerfile
+++ b/front/Dockerfile
@@ -3,8 +3,7 @@ FROM node:9
 EXPOSE 8080
 WORKDIR /app/
 ADD package.json .
-RUN yarn install --only=production
-RUN yarn install --only=dev
+RUN yarn install
 VOLUME ["/app/node_modules"]
 COPY . .
 
diff --git a/front/config/index.js b/front/config/index.js
index 7ce6e26e1c63ad12fc681a5317158856d30a99c8..14cbe3e4388ea6a5e18a6053a1611ffd0cd6ab38 100644
--- a/front/config/index.js
+++ b/front/config/index.js
@@ -32,6 +32,7 @@ module.exports = {
       '/api': {
         target: 'http://nginx:6001',
         changeOrigin: true,
+        ws: true
       },
       '/media': {
         target: 'http://nginx:6001',
diff --git a/front/package.json b/front/package.json
index d6bdb8c56caaeb8da80213800537ed255b5c960a..201694e43648e08c6bd23b2fa869ca83c29c7e2d 100644
--- a/front/package.json
+++ b/front/package.json
@@ -17,6 +17,7 @@
   "dependencies": {
     "axios": "^0.17.1",
     "dateformat": "^2.0.0",
+    "django-channels": "^1.1.6",
     "js-logger": "^1.3.0",
     "jwt-decode": "^2.2.0",
     "lodash": "^4.17.4",
diff --git a/front/src/App.vue b/front/src/App.vue
index 3e39d7262ccec891711e7d6a16c8454a53559aaa..b26959fe7006a72c45cac37e72d468c8f3c614ff 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -33,6 +33,9 @@
 </template>
 
 <script>
+import { WebSocketBridge } from 'django-channels'
+
+import logger from '@/logging'
 import Sidebar from '@/components/Sidebar'
 import Raven from '@/components/Raven'
 
@@ -44,6 +47,31 @@ export default {
   },
   created () {
     this.$store.dispatch('instance/fetchSettings')
+    this.openWebsocket()
+    let self = this
+    setInterval(() => {
+      // used to redraw ago dates every minute
+      self.$store.commit('ui/computeLastDate')
+    }, 1000 * 60)
+  },
+  methods: {
+    openWebsocket () {
+      let self = this
+      let token = this.$store.state.auth.token
+      // let token = 'test'
+      const bridge = new WebSocketBridge()
+      bridge.connect(
+        `/api/v1/instance/activity?token=${token}`,
+        null,
+        {reconnectInterval: 5000})
+      bridge.listen(function (event) {
+        logger.default.info('Received timeline update', event)
+        self.$store.commit('instance/event', event)
+      })
+      bridge.socket.addEventListener('open', function () {
+        console.log('Connected to WebSocket')
+      })
+    }
   }
 }
 </script>
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index 86ec578194df2d3b5d3dddc28fd4dd085884e5d1..f5229b2075c1811c24fcd758dc8db3611b2724a7 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -36,6 +36,9 @@
         <router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
         <router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link>
         <router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
+        <router-link
+          v-if="$store.state.auth.authenticated"
+          class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link>
       </div>
     </div>
     <div v-if="queue.previousQueue " class="ui black icon message">
diff --git a/front/src/components/activity/Like.vue b/front/src/components/activity/Like.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ffb8312787fa366490e0172a0f71dcdbffbfc77d
--- /dev/null
+++ b/front/src/components/activity/Like.vue
@@ -0,0 +1,35 @@
+<template>
+  <div class="event">
+   <div class="label">
+     <i class="pink heart icon"></i>
+   </div>
+   <div class="content">
+     <div class="summary">
+       <slot name="user"></slot>
+       favorited a track
+       <slot name="date"></slot>
+     </div>
+     <div class="extra text">
+       <router-link :to="{name: 'library.tracks.detail', params: {id: event.object.local_id }}">{{ event.object.name }}</router-link>
+        <template v-if="event.object.album">from album {{ event.object.album }}, by <em>{{ event.object.artist }}</em>
+        </template>
+        <template v-else>, by <em>{{ event.object.artist }}</em>
+        </template>
+
+      </div>
+   </div>
+ </div>
+
+</template>
+
+<script>
+
+export default {
+  props: ['event']
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped lang="scss">
+
+</style>
diff --git a/front/src/components/activity/Listen.vue b/front/src/components/activity/Listen.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7c8ee8a69e30666b7fc09e1264e75e6b49ade867
--- /dev/null
+++ b/front/src/components/activity/Listen.vue
@@ -0,0 +1,35 @@
+<template>
+  <div class="event">
+   <div class="label">
+     <i class="orange sound icon"></i>
+   </div>
+   <div class="content">
+     <div class="summary">
+       <slot name="user"></slot>
+       listened to a track
+       <slot name="date"></slot>
+     </div>
+     <div class="extra text">
+       <router-link :to="{name: 'library.tracks.detail', params: {id: event.object.local_id }}">{{ event.object.name }}</router-link>
+        <template v-if="event.object.album">from album {{ event.object.album }}, by <em>{{ event.object.artist }}</em>
+        </template>
+        <template v-else>, by <em>{{ event.object.artist }}</em>
+        </template>
+
+      </div>
+   </div>
+ </div>
+
+</template>
+
+<script>
+
+export default {
+  props: ['event']
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped lang="scss">
+
+</style>
diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue
index 4e8f33289b470526539e71ff40f6de32a2857dcb..8cd03d75580aa52f4e7e382a5a6ec0278cb3db92 100644
--- a/front/src/components/auth/Settings.vue
+++ b/front/src/components/auth/Settings.vue
@@ -2,12 +2,35 @@
   <div class="main pusher">
     <div class="ui vertical stripe segment">
       <div class="ui small text container">
-        <h2>Change my password</h2>
-        <form class="ui form" @submit.prevent="submit()">
-          <div v-if="error" class="ui negative message">
+        <h2 class="ui header">Account settings</h2>
+        <form class="ui form" @submit.prevent="submitSettings()">
+          <div v-if="settings.success" class="ui positive message">
+            <div class="header">Settings updated</div>
+          </div>
+          <div v-if="settings.errors.length > 0" class="ui negative message">
+            <div class="header">We cannot save your settings</div>
+            <ul class="list">
+              <li v-for="error in settings.errors">{{ error }}</li>
+            </ul>
+          </div>
+          <div class="field" v-for="f in orderedSettingsFields">
+            <label :for="f.id">{{ f.label }}</label>
+            <p v-if="f.help">{{ f.help }}</p>
+            <select v-if="f.type === 'dropdown'" class="ui dropdown" v-model="f.value">
+              <option :value="c.value" v-for="c in f.choices">{{ c.label }}</option>
+            </select>
+          </div>
+          <button :class="['ui', {'loading': isLoading}, 'button']" type="submit">Update settings</button>
+        </form>
+      </div>
+      <div class="ui hidden divider"></div>
+      <div class="ui small text container">
+        <h2 class="ui header">Change my password</h2>
+        <form class="ui form" @submit.prevent="submitPassword()">
+          <div v-if="passwordError" class="ui negative message">
             <div class="header">Cannot change your password</div>
             <ul class="list">
-              <li v-if="error == 'invalid_credentials'">Please double-check your password is correct</li>
+              <li v-if="passwordError == 'invalid_credentials'">Please double-check your password is correct</li>
             </ul>
           </div>
           <div class="field">
@@ -36,22 +59,68 @@
 </template>
 
 <script>
+import $ from 'jquery'
 import axios from 'axios'
 import logger from '@/logging'
 
 export default {
   data () {
-    return {
+    let d = {
       // We need to initialize the component with any
       // properties that will be used in it
       old_password: '',
       new_password: '',
-      error: '',
-      isLoading: false
+      passwordError: '',
+      isLoading: false,
+      settings: {
+        success: false,
+        errors: [],
+        order: ['privacy_level'],
+        fields: {
+          'privacy_level': {
+            type: 'dropdown',
+            initial: this.$store.state.auth.profile.privacy_level,
+            label: 'Activity visibility',
+            help: 'Determine the visibility level of your activity',
+            choices: [
+              {
+                value: 'me',
+                label: 'Nobody except me'
+              },
+              {
+                value: 'instance',
+                label: 'Everyone on this instance'
+              }
+            ]
+          }
+        }
+      }
     }
+    d.settings.order.forEach(id => {
+      d.settings.fields[id].value = d.settings.fields[id].initial
+    })
+    return d
+  },
+  mounted () {
+    $('select.dropdown').dropdown()
   },
   methods: {
-    submit () {
+    submitSettings () {
+      this.settings.success = false
+      this.settings.errors = []
+      let self = this
+      let payload = this.settingsValues
+      let url = `users/users/${this.$store.state.auth.username}/`
+      return axios.patch(url, payload).then(response => {
+        logger.default.info('Updated settings successfully')
+        self.settings.success = true
+      }, error => {
+        logger.default.error('Error while updating settings')
+        self.isLoading = false
+        self.settings.errors = error.backendErrors
+      })
+    },
+    submitPassword () {
       var self = this
       self.isLoading = true
       this.error = ''
@@ -70,13 +139,30 @@ export default {
           }})
       }, error => {
         if (error.response.status === 400) {
-          self.error = 'invalid_credentials'
+          self.passwordError = 'invalid_credentials'
         } else {
-          self.error = 'unknown_error'
+          self.passwordError = 'unknown_error'
         }
         self.isLoading = false
       })
     }
+  },
+  computed: {
+    orderedSettingsFields () {
+      let self = this
+      return this.settings.order.map(id => {
+        return self.settings.fields[id]
+      })
+    },
+    settingsValues () {
+      let self = this
+      let s = {}
+      this.settings.order.forEach(setting => {
+        let conf = self.settings.fields[setting]
+        s[setting] = conf.value
+      })
+      return s
+    }
   }
 
 }
diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue
index 13b723d201437933d9f6fd46bcaccfb2d316f5ed..749d2eb0254a5d55b163532afff252302b4d50e6 100644
--- a/front/src/components/auth/Signup.vue
+++ b/front/src/components/auth/Signup.vue
@@ -100,24 +100,9 @@ export default {
             username: this.username
           }})
       }, error => {
-        self.errors = this.getErrors(error.response)
+        self.errors = error.backendErrors
         self.isLoading = false
       })
-    },
-    getErrors (response) {
-      let errors = []
-      if (response.status !== 400) {
-        errors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running')
-        return errors
-      }
-      for (var field in response.data) {
-        if (response.data.hasOwnProperty(field)) {
-          response.data[field].forEach(e => {
-            errors.push(e)
-          })
-        }
-      }
-      return errors
     }
   },
   computed: {
diff --git a/front/src/components/common/HumanDate.vue b/front/src/components/common/HumanDate.vue
index ff6ff5c71efa2df05e1467751235ddb15dd67222..9ff8e48bdce8a8d366d7f8394571a7c00a8ec6f8 100644
--- a/front/src/components/common/HumanDate.vue
+++ b/front/src/components/common/HumanDate.vue
@@ -1,8 +1,20 @@
 <template>
-  <time :datetime="date" :title="date | moment">{{ date | ago }}</time>
+  <time :datetime="date" :title="date | moment">{{ realDate | ago }}</time>
 </template>
 <script>
+import {mapState} from 'vuex'
 export default {
-  props: ['date']
+  props: ['date'],
+  computed: {
+    ...mapState({
+      lastDate: state => state.ui.lastDate
+    }),
+    realDate () {
+      if (this.lastDate) {
+        // dummy code to trigger a recompute to update the ago render
+      }
+      return this.date
+    }
+  }
 }
 </script>
diff --git a/front/src/components/common/Username.vue b/front/src/components/common/Username.vue
new file mode 100644
index 0000000000000000000000000000000000000000..17fb34925c51249bb09f237c582ffbb4a9451c8d
--- /dev/null
+++ b/front/src/components/common/Username.vue
@@ -0,0 +1,8 @@
+<template>
+  <span>{{ username }}</span>
+</template>
+<script>
+export default {
+  props: ['username']
+}
+</script>
diff --git a/front/src/components/globals.js b/front/src/components/globals.js
index 40315bc47d8efa9731c7133354181e09bbd6cb59..b1d7d61041d31df9f9e47076e6ae273c8119c33b 100644
--- a/front/src/components/globals.js
+++ b/front/src/components/globals.js
@@ -4,4 +4,8 @@ import HumanDate from '@/components/common/HumanDate'
 
 Vue.component('human-date', HumanDate)
 
+import Username from '@/components/common/Username'
+
+Vue.component('username', Username)
+
 export default {}
diff --git a/front/src/components/library/import/BatchDetail.vue b/front/src/components/library/import/BatchDetail.vue
index 417fd55c2bc6f208208bfb16963d6b1d4d6f5d44..0864d2464abf9a769e9c15d1d6d32b3ebf21f219 100644
--- a/front/src/components/library/import/BatchDetail.vue
+++ b/front/src/components/library/import/BatchDetail.vue
@@ -62,12 +62,18 @@ export default {
   data () {
     return {
       isLoading: true,
-      batch: null
+      batch: null,
+      timeout: null
     }
   },
   created () {
     this.fetchData()
   },
+  destroyed () {
+    if (this.timeout) {
+      clearTimeout(this.timeout)
+    }
+  },
   methods: {
     fetchData () {
       var self = this
@@ -78,7 +84,7 @@ export default {
         self.batch = response.data
         self.isLoading = false
         if (self.batch.status === 'pending') {
-          setTimeout(
+          self.timeout = setTimeout(
             self.fetchData,
             5000
           )
diff --git a/front/src/main.js b/front/src/main.js
index caf924188086270a8d0e598c9a5307d3fa57c1e2..f20af42bfa7ed7ab3850cc8ebf76d6e4ae0916e7 100644
--- a/front/src/main.js
+++ b/front/src/main.js
@@ -47,11 +47,28 @@ axios.interceptors.request.use(function (config) {
 axios.interceptors.response.use(function (response) {
   return response
 }, function (error) {
+  error.backendErrors = []
   if (error.response.status === 401) {
     store.commit('auth/authenticated', false)
     logger.default.warn('Received 401 response from API, redirecting to login form')
     router.push({name: 'login', query: {next: router.currentRoute.fullPath}})
   }
+  if (error.response.status === 404) {
+    error.backendErrors.push('Resource not found')
+  } else if (error.response.status === 500) {
+    error.backendErrors.push('A server error occured')
+  } else if (error.response.data) {
+    for (var field in error.response.data) {
+      if (error.response.data.hasOwnProperty(field)) {
+        error.response.data[field].forEach(e => {
+          error.backendErrors.push(e)
+        })
+      }
+    }
+  }
+  if (error.backendErrors.length === 0) {
+    error.backendErrors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running')
+  }
   // Do something with response error
   return Promise.reject(error)
 })
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 827afc21823687dc266a69c601435015757a9045..ba9aadd981d844972a874a5fe0870a640b2f95e8 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -3,6 +3,7 @@ import Router from 'vue-router'
 import PageNotFound from '@/components/PageNotFound'
 import About from '@/components/About'
 import Home from '@/components/Home'
+import InstanceTimeline from '@/views/instance/Timeline'
 import Login from '@/components/auth/Login'
 import Signup from '@/components/auth/Signup'
 import Profile from '@/components/auth/Profile'
@@ -39,6 +40,11 @@ export default new Router({
       name: 'about',
       component: About
     },
+    {
+      path: '/activity',
+      name: 'activity',
+      component: InstanceTimeline
+    },
     {
       path: '/login',
       name: 'login',
diff --git a/front/src/store/index.js b/front/src/store/index.js
index 74f9d42b195be8d38aaa8f6a5745c895f029a346..2453c0e7134124e3f9d9dc3ffac47d2558a9827d 100644
--- a/front/src/store/index.js
+++ b/front/src/store/index.js
@@ -8,11 +8,13 @@ import instance from './instance'
 import queue from './queue'
 import radios from './radios'
 import player from './player'
+import ui from './ui'
 
 Vue.use(Vuex)
 
 export default new Vuex.Store({
   modules: {
+    ui,
     auth,
     favorites,
     instance,
@@ -28,6 +30,10 @@ export default new Vuex.Store({
         return mutation.type.startsWith('auth/')
       }
     }),
+    createPersistedState({
+      key: 'instance',
+      paths: ['instance.events']
+    }),
     createPersistedState({
       key: 'radios',
       paths: ['radios'],
diff --git a/front/src/store/instance.js b/front/src/store/instance.js
index a4dfcada65028f27a7fd4c61741b1f14d43d6dcd..2436eab079cd72f11fe48ecf2f64e857bc4f1e58 100644
--- a/front/src/store/instance.js
+++ b/front/src/store/instance.js
@@ -5,6 +5,8 @@ import _ from 'lodash'
 export default {
   namespaced: true,
   state: {
+    maxEvents: 200,
+    events: [],
     settings: {
       instance: {
         name: {
@@ -35,6 +37,12 @@ export default {
   mutations: {
     settings: (state, value) => {
       _.merge(state.settings, value)
+    },
+    event: (state, value) => {
+      state.events.unshift(value)
+      if (state.events.length > state.maxEvents) {
+        state.events = state.events.slice(0, state.maxEvents)
+      }
     }
   },
   actions: {
diff --git a/front/src/store/ui.js b/front/src/store/ui.js
new file mode 100644
index 0000000000000000000000000000000000000000..f0935e491bb1a48abc5f90d3482e3d5eab7ee9a5
--- /dev/null
+++ b/front/src/store/ui.js
@@ -0,0 +1,12 @@
+
+export default {
+  namespaced: true,
+  state: {
+    lastDate: new Date()
+  },
+  mutations: {
+    computeLastDate: (state) => {
+      state.lastDate = new Date()
+    }
+  }
+}
diff --git a/front/src/views/instance/Timeline.vue b/front/src/views/instance/Timeline.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b959c25d66d397fc24c3c4b9d2ab62f26cdfb9fb
--- /dev/null
+++ b/front/src/views/instance/Timeline.vue
@@ -0,0 +1,52 @@
+<template>
+  <div class="main pusher">
+    <div class="ui vertical center aligned stripe segment">
+      <div class="ui text container">
+        <h1 class="ui header">Recent activity on this instance</h1>
+        <div class="ui feed">
+          <component
+            class="event"
+            v-for="(event, index) in events"
+            :key="event.id + index"
+            v-if="components[event.type]"
+            :is="components[event.type]"
+            :event="event">
+            <username
+              class="user"
+              :username="event.actor.local_id"
+              slot="user"></username>
+              {{ event.published }}
+            <human-date class="date" :date="event.published" slot="date"></human-date>
+          </component>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import {mapState} from 'vuex'
+
+import Like from '@/components/activity/Like'
+import Listen from '@/components/activity/Listen'
+
+export default {
+  data () {
+    return {
+      components: {
+        'Like': Like,
+        'Listen': Listen
+      }
+    }
+  },
+  computed: {
+    ...mapState({
+      events: state => state.instance.events
+    })
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>