From 86504cf65b83145bffb9c2bfc487373049b76722 Mon Sep 17 00:00:00 2001
From: Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de>
Date: Tue, 13 Jun 2023 06:18:55 +0000
Subject: [PATCH] Refactor Blubber using vue, closes #1695

Closes #1695

Merge request studip/studip!1791
---
 app/controllers/blubber.php                   | 150 +++--
 app/controllers/course/messenger.php          |  47 +-
 app/views/blubber/dialog.php                  |  13 +-
 app/views/blubber/index.php                   |  25 +-
 .../JsonApiIntegration/QueryChecker.php       |   9 +-
 lib/classes/JsonApi/RouteMap.php              |  11 +-
 .../JsonApi/Routes/Blubber/Authority.php      |   5 +
 .../Routes/Blubber/CommentsByThreadIndex.php  |  20 +-
 .../JsonApi/Routes/Blubber/CommentsCreate.php |  30 +-
 .../Routes/Blubber/Rel/DefaultThread.php      | 128 +++++
 .../JsonApi/Routes/Blubber/SortTrait.php      |  15 +
 .../JsonApi/Routes/Blubber/ThreadsUpdate.php  |  56 ++
 .../JsonApi/Schemas/BlubberComment.php        |   4 +
 lib/classes/JsonApi/Schemas/BlubberThread.php |  75 ++-
 lib/classes/JsonApi/Schemas/User.php          |  27 +-
 lib/classes/sidebar/BlubberThreadsWidget.php  |  61 --
 lib/models/BlubberThread.php                  |  25 +-
 resources/assets/javascripts/chunk-loader.js  |   4 +-
 resources/assets/javascripts/entry-base.js    |   1 -
 resources/assets/javascripts/init.js          |   3 +
 resources/assets/javascripts/lib/blubber.js   | 266 +++------
 resources/assets/javascripts/lib/jsupdater.js |   6 +
 .../assets/stylesheets/scss/blubber.scss      |   2 +
 .../vue/components/BlubberGlobalstream.vue    | 130 -----
 .../vue/components/BlubberPublicComposer.vue  |  98 ----
 resources/vue/components/BlubberThread.vue    | 526 ------------------
 .../vue/components/BlubberThreadWidget.vue    | 116 ----
 resources/vue/components/SidebarWidget.vue    |  25 +-
 resources/vue/components/blubber/Comment.vue  | 118 ++++
 .../vue/components/blubber/CommunityPage.vue  |  89 +++
 resources/vue/components/blubber/Composer.vue | 126 +++++
 .../vue/components/blubber/DialogPanel.vue    |  36 ++
 resources/vue/components/blubber/Panel.vue    | 172 ++++++
 .../vue/components/blubber/SearchWidget.vue   |  62 +++
 resources/vue/components/blubber/SideInfo.vue |  16 +
 resources/vue/components/blubber/Thread.vue   | 252 +++++++++
 .../components/blubber/ThreadSubscriber.vue   |  32 ++
 .../vue/components/blubber/ThreadsWidget.vue  | 109 ++++
 .../vue/components/blubber/components.js      |  10 +
 resources/vue/plugins/blubber.js              |  63 +++
 resources/vue/store/blubber.js                | 364 ++++++++++++
 templates/blubber/threads-overview.php        |  16 -
 tests/jsonapi/BlubberThreadsCreateTest.php    |   6 +
 tests/jsonapi/BlubberThreadsIndexTest.php     |   6 +
 tests/jsonapi/BlubberThreadsShowTest.php      |   6 +
 tests/jsonapi/_bootstrap.php                  |   3 +
 46 files changed, 2042 insertions(+), 1322 deletions(-)
 create mode 100644 lib/classes/JsonApi/Routes/Blubber/Rel/DefaultThread.php
 create mode 100644 lib/classes/JsonApi/Routes/Blubber/SortTrait.php
 create mode 100644 lib/classes/JsonApi/Routes/Blubber/ThreadsUpdate.php
 delete mode 100644 lib/classes/sidebar/BlubberThreadsWidget.php
 delete mode 100644 resources/vue/components/BlubberGlobalstream.vue
 delete mode 100644 resources/vue/components/BlubberPublicComposer.vue
 delete mode 100644 resources/vue/components/BlubberThread.vue
 delete mode 100644 resources/vue/components/BlubberThreadWidget.vue
 create mode 100644 resources/vue/components/blubber/Comment.vue
 create mode 100644 resources/vue/components/blubber/CommunityPage.vue
 create mode 100644 resources/vue/components/blubber/Composer.vue
 create mode 100644 resources/vue/components/blubber/DialogPanel.vue
 create mode 100644 resources/vue/components/blubber/Panel.vue
 create mode 100644 resources/vue/components/blubber/SearchWidget.vue
 create mode 100644 resources/vue/components/blubber/SideInfo.vue
 create mode 100644 resources/vue/components/blubber/Thread.vue
 create mode 100644 resources/vue/components/blubber/ThreadSubscriber.vue
 create mode 100644 resources/vue/components/blubber/ThreadsWidget.vue
 create mode 100644 resources/vue/components/blubber/components.js
 create mode 100644 resources/vue/plugins/blubber.js
 create mode 100644 resources/vue/store/blubber.js
 delete mode 100644 templates/blubber/threads-overview.php

diff --git a/app/controllers/blubber.php b/app/controllers/blubber.php
index d89916ccabd..71d89f3e127 100644
--- a/app/controllers/blubber.php
+++ b/app/controllers/blubber.php
@@ -15,20 +15,16 @@ class BlubberController extends AuthenticatedController
 
     public function index_action($thread_id = null)
     {
-        Navigation::activateItem('/community/blubber');
-
-        $this->threads = BlubberThread::findMyGlobalThreads(
-            51,
-            null,
-            null,
-            null,
-            Request::get("search")
-        );
+        if (Navigation::hasItem('/community/blubber')) {
+            Navigation::activateItem('/community/blubber');
+        }
+
+        $this->search = Request::get('search');
+        $this->threads = BlubberThread::findMyGlobalThreads(21, null, null, null, $this->search);
         if (count($this->threads) > 20) {
             array_pop($this->threads);
             $this->threads_more_down = 1;
         }
-
         if ($thread_id) {
             $GLOBALS['user']->cfg->store('BLUBBER_DEFAULT_THREAD', $thread_id);
         } else {
@@ -47,14 +43,8 @@ class BlubberController extends AuthenticatedController
             $this->thread = array_pop($threads);
         }
 
-        $this->thread_data = [];
         if ($this->thread) {
             $this->thread->markAsRead();
-            $this->thread_data = $this->thread->getJSONData(
-                50,
-                null,
-                Request::get("search")
-            );
         }
 
         if (
@@ -62,19 +52,20 @@ class BlubberController extends AuthenticatedController
             && !Avatar::getAvatar($GLOBALS['user']->id)->is_customized()
         ) {
             $_SESSION['already_asked_for_avatar'] = true;
-            PageLayout::postInfo(sprintf(
-                _('Wollen Sie ein Avatar-Bild nutzen? %sLaden Sie jetzt ein Bild hoch%s.'),
-                '<a href="' . URLHelper::getLink("dispatch.php/avatar/update/user/" . $GLOBALS['user']->id) . '" data-dialog>',
-                '</a>'
-            ));
-        }
-
-        if (Request::isDialog()) {
-            PageLayout::setTitle($this->thread->getName());
+            PageLayout::postInfo(
+                sprintf(
+                    _('Wollen Sie ein Avatar-Bild nutzen? %sLaden Sie jetzt ein Bild hoch%s.'),
+                    '<a href="' .
+                        URLHelper::getLink('dispatch.php/avatar/update/user/' . $GLOBALS['user']->id) .
+                        '" data-dialog>',
+                    '</a>'
+                )
+            );
         }
         $this->buildSidebar();
 
         if (Request::isDialog()) {
+            PageLayout::setTitle($this->thread->getName());
             $this->render_template('blubber/dialog');
         }
     }
@@ -111,7 +102,7 @@ class BlubberController extends AuthenticatedController
                 $statement = DBManager::get()->prepare($query);
                 $statement->execute([
                     'me' => $GLOBALS['user']->id,
-                    'friend' => $user_ids[0]
+                    'friend' => $user_ids[0],
                 ]);
                 $thread_id = $statement->fetchColumn();
                 if ($thread_id) {
@@ -141,16 +132,19 @@ class BlubberController extends AuthenticatedController
             foreach ($user_ids as $user_id) {
                 $insert->execute([
                     'thread_id' => $blubber->getId(),
-                    'user_id'   => $user_id,
+                    'user_id' => $user_id,
                 ]);
             }
             $this->redirect("blubber/index/{$blubber->getId()}");
             return;
         }
 
-        $this->contacts = Contact::findBySQL("JOIN auth_user_md5 USING (user_id) WHERE owner_id = ? ORDER BY auth_user_md5.Nachname ASC, auth_user_md5.Vorname ASC", [
-            $GLOBALS['user']->id
-        ]);
+        $this->contacts = Contact::findBySQL(
+            "JOIN auth_user_md5 USING (user_id)
+             WHERE owner_id = ?
+             ORDER BY auth_user_md5.Nachname, auth_user_md5.Vorname",
+            [$GLOBALS['user']->id]
+        );
     }
 
     public function delete_action($thread_id)
@@ -164,7 +158,7 @@ class BlubberController extends AuthenticatedController
             $this->thread->delete();
             PageLayout::postSuccess(_('Der Blubber wurde gelöscht.'));
         }
-        $this->redirect("blubber/index");
+        $this->redirect('blubber/index');
         return;
     }
 
@@ -194,7 +188,7 @@ class BlubberController extends AuthenticatedController
                       LIMIT 1";
             $statement = DBManager::get()->prepare($query);
             $statement->execute([
-                'me'     => $GLOBALS['user']->id,
+                'me' => $GLOBALS['user']->id,
                 'friend' => $user_ids[0],
             ]);
             $thread_id = $statement->fetchColumn();
@@ -225,7 +219,7 @@ class BlubberController extends AuthenticatedController
         foreach ($user_ids as $user_id) {
             $insert->execute([
                 'thread_id' => $blubber->getId(),
-                'user_id'   => $user_id,
+                'user_id' => $user_id,
             ]);
         }
         $this->redirect("blubber/index/{$blubber->getId()}");
@@ -265,8 +259,12 @@ class BlubberController extends AuthenticatedController
     {
         $context = Request::get('context', $GLOBALS['user']->id);
         $context_type = Request::option('context_type');
-        if (!Request::isPost()
-            || ($context_type === 'course' && !$GLOBALS['perm']->have_studip_perm('autor', $context))
+        if (
+            !Request::isPost()
+            || (
+                $context_type === 'course'
+                && !$GLOBALS['perm']->have_studip_perm('autor', $context)
+            )
         ) {
             throw new AccessDeniedException();
         }
@@ -276,7 +274,6 @@ class BlubberController extends AuthenticatedController
             $newfile = null; //is filled below
             $file_ref = null; //is also filled below
 
-
             if ($file['size']) {
                 $document['user_id'] = $GLOBALS['user']->id;
                 $document['filesize'] = $file['size'];
@@ -290,11 +287,10 @@ class BlubberController extends AuthenticatedController
                          AND data_content = :content",
                         [
                             'parent_id' => $root_dir->getId(),
-                            'content'   => json_encode(['Blubber']),
+                            'content' => json_encode(['Blubber']),
                         ]
                     );
 
-
                     if ($blubber_directory) {
                         $blubber_directory = $blubber_directory->getTypedFolder();
                     } else {
@@ -321,10 +317,10 @@ class BlubberController extends AuthenticatedController
                         $uploaded = FileManager::handleFileUpload(
                             [
                                 'tmp_name' => [$file['tmp_name']],
-                                'name'     => [$file['name']],
-                                'size'     => [$file['size']],
-                                'type'     => [$file['type']],
-                                'error'    => [$file['error']]
+                                'name' => [$file['name']],
+                                'size' => [$file['size']],
+                                'type' => [$file['type']],
+                                'error' => [$file['error']],
                             ],
                             $blubber_directory,
                             $GLOBALS['user']->id
@@ -332,7 +328,7 @@ class BlubberController extends AuthenticatedController
 
                         if ($uploaded['error']) {
                             throw new Exception(implode("\n", $uploaded['error']));
-                        } elseif($uploaded['files'][0]) {
+                        } elseif ($uploaded['files'][0]) {
                             $oldbase = URLHelper::setBaseURL($GLOBALS['ABSOLUTE_URI_STUDIP']);
                             $url = $uploaded['files'][0]->getDownloadURL();
                             URLHelper::setBaseURL($oldbase);
@@ -340,14 +336,12 @@ class BlubberController extends AuthenticatedController
                         } else {
                             throw new Exception('File cannot be created!');
                         }
-
                     }
                 } catch (Exception $e) {
                     $output['errors'][] = $e->getMessage();
                     $success = false;
                 }
 
-
                 if ($success) {
                     $type = null;
 
@@ -387,12 +381,9 @@ class BlubberController extends AuthenticatedController
             $statement = DBManager::get()->prepare($query);
             $statement->execute([
                 'thread_id' => $thread_id,
-                'user_id'   => Request::option('user_id'),
+                'user_id' => Request::option('user_id'),
             ]);
-            $this->response->add_header(
-                'X-Dialog-Execute',
-                'STUDIP.Blubber.refreshThread'
-            );
+            $this->response->add_header('X-Dialog-Execute', 'STUDIP.Blubber.refreshThread');
             $this->response->add_header('X-Dialog-Close', '1');
             $this->render_json([
                 'thread_id' => $thread_id,
@@ -405,7 +396,7 @@ class BlubberController extends AuthenticatedController
         if ($this->thread['context_type'] !== 'private' || !$this->thread->isReadable()) {
             throw new AccessDeniedException();
         }
-        PageLayout::setTitle(_("Studiengruppe aus Konversation erstellen"));
+        PageLayout::setTitle(_('Studiengruppe aus Konversation erstellen'));
         if (Request::isPost() && count(studygroup_sem_types())) {
             $studgroup_sem_types = studygroup_sem_types();
             $course = new Course();
@@ -436,7 +427,9 @@ class BlubberController extends AuthenticatedController
             $this->thread->store();
 
             PluginManager::getInstance()->setPluginActivated(
-                PluginManager::getInstance()->getPlugin('Blubber')->getPluginId(),
+                PluginManager::getInstance()
+                    ->getPlugin('Blubber')
+                    ->getPluginId(),
                 $course->getId(),
                 true
             );
@@ -451,62 +444,47 @@ class BlubberController extends AuthenticatedController
         if ($this->thread['context_type'] !== 'private' || !$this->thread->isReadable()) {
             throw new AccessDeniedException();
         }
-        PageLayout::setTitle(_("Private Konversation verlassen"));
+        PageLayout::setTitle(_('Private Konversation verlassen'));
         if (Request::isPost()) {
             BlubberMention::deleteBySQL("user_id = :me AND external_contact = '0' AND thread_id = :thread_id", [
                 'thread_id' => $this->thread->getId(),
-                'me' => $GLOBALS['user']->id
+                'me' => $GLOBALS['user']->id,
             ]);
-            if (Request::get("delete_comments")) {
+            if (Request::get('delete_comments')) {
                 BlubberComment::deleteBySQL("thread_id = :thread_id AND user_id = :me AND external_contact = '0'", [
                     'thread_id' => $this->thread->getId(),
-                    'me' => $GLOBALS['user']->id
+                    'me' => $GLOBALS['user']->id,
                 ]);
             }
             if ($this->thread['user_id'] === $GLOBALS['user']->id) {
-                $this->thread['content'] = "";
+                $this->thread['content'] = '';
                 $this->thread->store();
             }
-            $count_departed = BlubberMention::countBySQL("INNER JOIN auth_user_md5 USING (user_id) WHERE external_contact = '0' AND thread_id = :thread_id", [
-                'thread_id' => $this->thread->getId()
-            ]);
+            $count_departed = BlubberMention::countBySQL(
+                "JOIN auth_user_md5 USING (user_id)
+                 WHERE external_contact = 0 AND thread_id = :thread_id",
+                [
+                    'thread_id' => $this->thread->getId(),
+                ]
+            );
             $count_comments = BlubberComment::countBySQL("thread_id = :thread_id AND external_contact = '0'", [
-                'thread_id' => $this->thread->getId()
+                'thread_id' => $this->thread->getId(),
             ]);
             if (!$count_departed || (!$count_comments && !$this->thread['content'])) {
                 //ich mache das Licht aus:
                 $this->thread->delete();
-                PageLayout::postSuccess(_("Private Konversation gelöscht."));
+                PageLayout::postSuccess(_('Private Konversation gelöscht.'));
             } else {
-                PageLayout::postSuccess(_("Private Konversation verlassen."));
+                PageLayout::postSuccess(_('Private Konversation verlassen.'));
             }
-            $this->redirect("blubber/index");
+            $this->redirect('blubber/index');
         }
     }
 
     protected function buildSidebar()
     {
-        $search = new SearchWidget("#");
-        $search->addNeedle(
-            _("Suche nach ..."),
-            "search",
-            true
-        );
-
-        Sidebar::Get()->addWidget($search, "blubbersearch");
-
-        $threads_widget = Sidebar::Get()->addWidget(
-            new BlubberThreadsWidget(),
-            'threads'
-        );
-        foreach ($this->threads as $thread) {
-            $threads_widget->addThread($thread);
-        }
-
-        if ($this->thread) {
-            $threads_widget->setActive($this->thread->getId());
-        }
-
-        $threads_widget->withComposer();
+        $sidebar = Sidebar::Get();
+        $sidebar->addWidget(new VueWidget('blubber-search-widget'));
+        $sidebar->addWidget(new VueWidget('blubber-threads-widget'));
     }
 }
diff --git a/app/controllers/course/messenger.php b/app/controllers/course/messenger.php
index 79db8499cee..8acd24b135e 100644
--- a/app/controllers/course/messenger.php
+++ b/app/controllers/course/messenger.php
@@ -6,7 +6,8 @@ class Course_MessengerController extends AuthenticatedController
         parent::before_filter($action, $args);
 
         PageLayout::setBodyElementId('blubber-index');
-        PageLayout::setHelpKeyword("Basis/InteraktionBlubber");
+        PageLayout::setHelpKeyword('Basis/InteraktionBlubber');
+        PageLayout::setTitle(_('Blubber'));
     }
 
     public function course_action($thread_id = null)
@@ -17,6 +18,7 @@ class Course_MessengerController extends AuthenticatedController
             Navigation::activateItem('/course/blubber');
         }
 
+        $this->search = '';
         $this->threads = BlubberThread::findByContext(Context::get()->id, true, Context::getType());
         $this->thread = null;
         $this->threads_more_down = 0;
@@ -32,17 +34,26 @@ class Course_MessengerController extends AuthenticatedController
                 }
             }
         }
-        if (!$this->thread || Request::get("thread") === "new") {
+        if (!$this->thread || Request::get('thread') === 'new') {
             $threads = array_reverse($this->threads);
             $this->thread = array_pop($threads);
         }
-        $this->thread->markAsRead();
 
-        $this->thread_data = $this->thread->getJSONData();
-        $_SESSION['already_asked_for_avatar'] = false;
-        if (!Avatar::getAvatar($GLOBALS['user']->id)->is_customized() && !$_SESSION['already_asked_for_avatar']) {
+        if ($this->thread) {
+            $this->thread->markAsRead();
+        }
+
+        if (!Avatar::getAvatar($GLOBALS['user']->id)->is_customized()) {
             $_SESSION['already_asked_for_avatar'] = true;
-            PageLayout::postInfo(sprintf(_("Wollen Sie ein Avatar-Bild nutzen? %sLaden Sie jetzt ein Bild hoch%s."), '<a href="'.URLHelper::getURL("dispatch.php/avatar/update/user/".$GLOBALS['user']->id).'" data-dialog>', '</a>'));
+            PageLayout::postInfo(
+                sprintf(
+                    _('Wollen Sie ein Avatar-Bild nutzen? %sLaden Sie jetzt ein Bild hoch%s.'),
+                    '<a href="' .
+                        URLHelper::getURL('dispatch.php/avatar/update/user/' . $GLOBALS['user']->id) .
+                        '" data-dialog>',
+                    '</a>'
+                )
+            );
         }
         $this->buildSidebar();
 
@@ -57,25 +68,7 @@ class Course_MessengerController extends AuthenticatedController
     protected function buildSidebar()
     {
         $sidebar = Sidebar::Get();
-        $search = new SearchWidget("#");
-        $search->addNeedle(
-            _("Suche nach ..."),
-            "search",
-            true,
-            null,
-            null,
-            null,
-            []
-        );
-        $sidebar->addWidget($search, "blubbersearch");
-
-        $threads_widget = new BlubberThreadsWidget();
-        foreach ($this->threads as $thread) {
-            $threads_widget->addThread($thread);
-        }
-        if ($this->thread) {
-            $threads_widget->setActive($this->thread->getId());
-        }
-        $sidebar->addWidget($threads_widget, "threads");
+        $sidebar->addWidget(new VueWidget('blubber-search-widget'));
+        $sidebar->addWidget(new VueWidget('blubber-threads-widget'));
     }
 }
diff --git a/app/views/blubber/dialog.php b/app/views/blubber/dialog.php
index 6be9bfdb888..f790a5d64c7 100644
--- a/app/views/blubber/dialog.php
+++ b/app/views/blubber/dialog.php
@@ -1,12 +1,5 @@
-<div class="blubber_panel"
-     data-thread_data="<?= htmlReady(json_encode($thread_data ?: [])) ?>"
-     data-threads_more_down="<?= htmlReady($threads_more_down) ?>">
-
-    <div id="blubber_stream_container" :class="waiting ? 'waiting' : ''">
-        <blubber-thread :thread_data="thread_data"></blubber-thread>
-    </div>
-</div>
+<?= $this->render_partial('blubber/index') ?>
 
 <div data-dialog-button>
-    <?= \Studip\LinkButton::create(_("Zum Kontext springen"), $thread->getURL()) ?>
-</div>
\ No newline at end of file
+    <?= \Studip\LinkButton::create(_('Zum Kontext springen'), $thread->getURL()) ?>
+</div>
diff --git a/app/views/blubber/index.php b/app/views/blubber/index.php
index 0055f97fa0e..e168f4a4dfc 100644
--- a/app/views/blubber/index.php
+++ b/app/views/blubber/index.php
@@ -1,21 +1,6 @@
 <div class="blubber_panel"
-     data-active_thread="<?= htmlReady(!empty($thread) ? $thread->getId() : '') ?>"
-     data-thread_data="<?= htmlReady(json_encode($thread_data ?: ['thread_posting' => []])) ?>"
-     data-threads_more_down="<?= htmlReady($threads_more_down) ?>"
-     :class="waiting ? 'waiting' : ''" v-cloak>
-
-    <div id="blubber_stream_container">
-        <blubber-thread :thread_data="thread_data"></blubber-thread>
-    </div>
-
-    <div class="blubber_sideinfo responsive-hidden" v-if="thread_data.context_info || thread_data.thread_posting.content">
-        <div class="posting" v-show="display_context_posting">
-            <div class="header">
-                <studip-date-time :timestamp="thread_data.thread_posting.mkdate" :relative="true"></studip-date-time>
-                <div>{{ thread_data.thread_posting.user_name }}</div>
-            </div>
-            <div class="content" v-html="thread_data.thread_posting.html"></div>
-        </div>
-        <div v-if="thread_data.context_info" class="context_info" v-html="thread_data.context_info"></div>
-    </div>
-</div>
+    <?= arrayToHtmlAttributes([
+        'data-initial-thread-id' => !empty($thread) ? $thread->getId() : '',
+        'data-search' => $search,
+    ]) ?>
+></div>
diff --git a/lib/classes/JsonApi/JsonApiIntegration/QueryChecker.php b/lib/classes/JsonApi/JsonApiIntegration/QueryChecker.php
index ff2a03d26de..045598b510f 100644
--- a/lib/classes/JsonApi/JsonApiIntegration/QueryChecker.php
+++ b/lib/classes/JsonApi/JsonApiIntegration/QueryChecker.php
@@ -104,12 +104,13 @@ class QueryChecker
 
     protected function checkSorting(ErrorCollection $errors, QueryParserInterface $queryParser): void
     {
-        if (null !== $queryParser->getSorts() && null !== $this->sortParameters) {
-            foreach ($queryParser->getSorts() as $sortParameter) {
-                if (!array_key_exists($sortParameter->getField(), $this->sortParameters)) {
+        $sorts = iterator_to_array($queryParser->getSorts());
+        if (null !== $sorts && null !== $this->sortParameters) {
+            foreach (array_keys($sorts) as $sortParameter) {
+                if (!array_key_exists($sortParameter, $this->sortParameters)) {
                     $errors->addQueryParameterError(
                         QueryParser::PARAM_SORT,
-                        sprintf('Sort parameter %s is not allowed.', $sortParameter->getField())
+                        sprintf('Sort parameter %s is not allowed.', $sortParameter)
                     );
                 }
             }
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index bffa13a59bd..bef6327d19c 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -173,21 +173,30 @@ class RouteMap
         $group->get('/users/{id}/blubber-threads', Routes\Blubber\ThreadsIndex::class)->setArgument('type', 'private');
         $group->get('/blubber-threads', Routes\Blubber\ThreadsIndex::class)->setArgument('type', 'all');
         $group->get('/blubber-threads/{id}', Routes\Blubber\ThreadsShow::class);
+        $group->patch('/blubber-threads/{id}', Routes\Blubber\ThreadsUpdate::class);
 
         // create, read, update and delete BlubberComments
         $group->get('/blubber-threads/{id}/comments', Routes\Blubber\CommentsByThreadIndex::class);
         $group->post('/blubber-threads/{id}/comments', Routes\Blubber\CommentsCreate::class);
         $group->get('/blubber-comments', Routes\Blubber\CommentsIndex::class);
         $group->get('/blubber-comments/{id}', Routes\Blubber\CommentsShow::class);
+        $group->post('/blubber-comments', Routes\Blubber\CommentsCreate::class);
         $group->patch('/blubber-comments/{id}', Routes\Blubber\CommentsUpdate::class);
         $group->delete('/blubber-comments/{id}', Routes\Blubber\CommentsDelete::class);
 
-        // REL mentions
+        // REL blubber-threads > mentions
         $this->addRelationship(
             $group,
             '/blubber-threads/{id}/relationships/mentions',
             Routes\Blubber\Rel\Mentions::class
         );
+
+        // REL users > blubber-default-thread
+        $this->addRelationship(
+            $group,
+            '/users/{id}/relationships/blubber-default-thread',
+            Routes\Blubber\Rel\DefaultThread::class
+        );
     }
 
     private function addAuthenticatedConsultationRoutes(RouteCollectorProxy $group): void
diff --git a/lib/classes/JsonApi/Routes/Blubber/Authority.php b/lib/classes/JsonApi/Routes/Blubber/Authority.php
index 8ab431e3720..9d4cf67b188 100644
--- a/lib/classes/JsonApi/Routes/Blubber/Authority.php
+++ b/lib/classes/JsonApi/Routes/Blubber/Authority.php
@@ -14,6 +14,11 @@ class Authority
         return self::userIsAuthor($user) && $resource->isReadable($user->id);
     }
 
+    public static function canEditBlubberThread(User $user, BlubberThread $resource): bool
+    {
+        return self::canShowBlubberThread($user, $resource);
+    }
+
     public static function canCreatePrivateBlubberThread(User $user)
     {
         return self::userIsAuthor($user);
diff --git a/lib/classes/JsonApi/Routes/Blubber/CommentsByThreadIndex.php b/lib/classes/JsonApi/Routes/Blubber/CommentsByThreadIndex.php
index f3222ef13e8..fb8b50d6edc 100644
--- a/lib/classes/JsonApi/Routes/Blubber/CommentsByThreadIndex.php
+++ b/lib/classes/JsonApi/Routes/Blubber/CommentsByThreadIndex.php
@@ -14,11 +14,14 @@ use Psr\Http\Message\ServerRequestInterface as Request;
  */
 class CommentsByThreadIndex extends JsonApiController
 {
-    use TimestampTrait, FilterTrait;
+    use FilterTrait;
+    use SortTrait;
+    use TimestampTrait;
 
     protected $allowedFilteringParameters = ['since', 'before', 'search'];
-    protected $allowedIncludePaths = ['author', 'mentions', 'thread'];
+    protected $allowedIncludePaths = ['author', 'mentions', 'thread', 'thread.author'];
     protected $allowedPagingParameters = ['offset', 'limit'];
+    protected $allowedSortFields = ['mkdate'];
 
     /**
      * @SuppressWarnings(PHPMD.UnusedFormalParameter)
@@ -49,12 +52,12 @@ class CommentsByThreadIndex extends JsonApiController
         $params = ['thread_id' => $thread->id];
 
         if (isset($filters['before'])) {
-            $query .= ' AND mkdate <= :before';
+            $query .= ' AND mkdate < :before';
             $params['before'] = $filters['before'];
         }
 
         if (isset($filters['since'])) {
-            $query .= ' AND mkdate >= :since';
+            $query .= ' AND mkdate > :since';
             $params['since'] = $filters['since'];
         }
 
@@ -63,7 +66,14 @@ class CommentsByThreadIndex extends JsonApiController
             $params['search'] = '%' . $filters['search'] . '%';
         }
 
-        $query .= ' ORDER BY mkdate ASC LIMIT :limit OFFSET :offset';
+        $sortParameters = $this->getSortParameters();
+        if (empty($sortParameters)) {
+            $query .= ' ORDER BY mkdate';
+        } elseif (array_key_exists('mkdate', $sortParameters)) {
+            $query .= ' ORDER BY mkdate ' . ($sortParameters['mkdate'] ? 'ASC' : 'DESC');
+        }
+
+        $query .= ' LIMIT :limit OFFSET :offset';
         $params['limit'] = $limit + 1;
         $params['offset'] = $offset;
 
diff --git a/lib/classes/JsonApi/Routes/Blubber/CommentsCreate.php b/lib/classes/JsonApi/Routes/Blubber/CommentsCreate.php
index 3548645bf35..6d9502032eb 100644
--- a/lib/classes/JsonApi/Routes/Blubber/CommentsCreate.php
+++ b/lib/classes/JsonApi/Routes/Blubber/CommentsCreate.php
@@ -9,7 +9,7 @@ use JsonApi\Errors\BadRequestException;
 use JsonApi\Errors\InternalServerError;
 use JsonApi\Errors\RecordNotFoundException;
 use JsonApi\JsonApiController;
-
+use JsonApi\Schemas\BlubberThread as ThreadSchema;
 use JsonApi\Routes\ValidationTrait;
 
 /**
@@ -24,9 +24,15 @@ class CommentsCreate extends JsonApiController
      */
     public function __invoke(Request $request, Response $response, $args)
     {
-        $json = $this->validate($request);
+        if (isset($args['id'])) {
+            $json = $this->validate($request, $args['id']);
+            $thread = \BlubberThread::find($args['id']);
+        } else {
+            $json = $this->validate($request, null);
+            $thread = $this->getThreadFromJson($json);
+        }
 
-        if (!($thread = \BlubberThread::find($args['id']))) {
+        if (!$thread) {
             throw new RecordNotFoundException();
         }
 
@@ -40,16 +46,30 @@ class CommentsCreate extends JsonApiController
             'thread_id' => $thread->id,
             'content' => $content,
             'user_id' => $user->id,
-            'external_contact' => 0
+            'external_contact' => 0,
         ]);
 
         return $this->getCreatedResponse($comment);
     }
 
-    protected function validateResourceDocument($json, $data)
+    protected function validateResourceDocument($json, $id = null)
     {
         if (empty(self::arrayGet($json, 'data.attributes.content'))) {
             return 'Comment should not be empty.';
         }
+        if (!$id && !$this->getThreadFromJson($json)) {
+            return 'Invalid `block` relationship.';
+        }
+    }
+
+    private function getThreadFromJson($json)
+    {
+        $relationship = 'thread';
+        if (!$this->validateResourceObject($json, 'data.relationships.' . $relationship, ThreadSchema::TYPE)) {
+            return null;
+        }
+        $resourceId = self::arrayGet($json, 'data.relationships.' . $relationship . '.data.id');
+
+        return \BlubberThread::find($resourceId);
     }
 }
diff --git a/lib/classes/JsonApi/Routes/Blubber/Rel/DefaultThread.php b/lib/classes/JsonApi/Routes/Blubber/Rel/DefaultThread.php
new file mode 100644
index 00000000000..91eb3b8a1a8
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Blubber/Rel/DefaultThread.php
@@ -0,0 +1,128 @@
+<?php
+
+namespace JsonApi\Routes\Blubber\Rel;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use JsonApi\Routes\Blubber\Authority as BlubberAuthority;
+use JsonApi\Routes\Users\Authority as UsersAuthority;
+use JsonApi\Routes\RelationshipsController;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Schemas\User as UserSchema;
+
+class DefaultThread extends RelationshipsController
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     * @param \User $related
+     */
+    protected function fetchRelationship(Request $request, $related)
+    {
+        $threadId = $related->getConfiguration()->getValue('BLUBBER_DEFAULT_THREAD');
+        $thread = \BlubberThread::find($threadId);
+
+        return $this->getIdentifiersResponse($thread);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    protected function replaceRelationship(Request $request, $related)
+    {
+        $json = $this->validate($request);
+        $thread = isset($json['data']) ? $this->validateBlubberThread($related, $json) : null;
+        $this->replaceBlubberDefaultThread($related, $thread);
+
+        return $this->getCodeResponse(204);
+    }
+
+    private function replaceBlubberDefaultThread(\User $related, $threadOrNull)
+    {
+        $related->getConfiguration()->store('BLUBBER_DEFAULT_THREAD', $threadOrNull ? $threadOrNull->id : null);
+    }
+
+    protected function findRelated(array $args)
+    {
+        $user = \User::find($args['id']);
+        if (!$user) {
+            throw new RecordNotFoundException();
+        }
+
+        return $user;
+    }
+
+    /**
+     * @param \User $resource
+     */
+    protected function authorize(Request $request, $resource)
+    {
+        switch ($request->getMethod()) {
+            case 'GET':
+            case 'PATCH':
+                return UsersAuthority::canEditUser($this->getUser($request), $resource);
+
+            default:
+                return false;
+        }
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+
+        $item = self::arrayGet($json, 'data');
+
+        if ($item !== null) {
+            if (\JsonApi\Schemas\BlubberThread::TYPE !== self::arrayGet($item, 'type')) {
+                return 'Wrong `type` in document´s `data`.';
+            }
+
+            if (!self::arrayGet($item, 'id')) {
+                return 'Missing `id` of document´s `data`.';
+            }
+
+            if (self::arrayHas($item, 'attributes')) {
+                return 'Document must not have `attributes`.';
+            }
+        }
+    }
+
+    private function validateBlubberThread(\User $user, $json)
+    {
+        $resourceIdentifier = self::arrayGet($json, 'data');
+        $thread = \BlubberThread::find($resourceIdentifier['id']);
+
+        if (!$thread) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!BlubberAuthority::canShowBlubberThread($user, $thread)) {
+            throw new BadRequestException('User is not able to access given thread.');
+        }
+
+        return $thread;
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     * @param \User $resource
+     */
+    protected function getRelationshipSelfLink($resource, $schema, $userData)
+    {
+        return $schema->getRelationshipSelfLink($resource, UserSchema::REL_BLUBBER_DEFAULT_THREAD);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     * @param \User $resource
+     */
+    protected function getRelationshipRelatedLink($resource, $schema, $userData)
+    {
+        return $schema->getRelationshipRelatedLink($resource, UserSchema::REL_BLUBBER_DEFAULT_THREAD);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Blubber/SortTrait.php b/lib/classes/JsonApi/Routes/Blubber/SortTrait.php
new file mode 100644
index 00000000000..ee577ce4f0b
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Blubber/SortTrait.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace JsonApi\Routes\Blubber;
+
+use JsonApi\Errors\BadRequestException;
+
+trait SortTrait
+{
+    private function getSortParameters(): array
+    {
+        $sortParameters = iterator_to_array($this->getQueryParameters()->getSorts()) ?? [];
+
+        return $sortParameters;
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Blubber/ThreadsUpdate.php b/lib/classes/JsonApi/Routes/Blubber/ThreadsUpdate.php
new file mode 100644
index 00000000000..a85db30e95c
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Blubber/ThreadsUpdate.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace JsonApi\Routes\Blubber;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+
+/**
+ * Update a blubber thread.
+ */
+class ThreadsUpdate extends JsonApiController
+{
+    use TimestampTrait;
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $json = $this->validate($request);
+
+        $thread = \BlubberThread::find($args['id']);
+        if (!$thread) {
+            throw new RecordNotFoundException();
+        }
+
+        $user = $this->getUser($request);
+        if (!Authority::canEditBlubberThread($user, $thread)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $visitedAt = self::arrayGet($json, 'data.attributes.visited-at');
+        if ($visitedAt) {
+            $visitedDate = self::fromISO8601($visitedAt)->getTimestamp();
+            $GLOBALS['user']->cfg->store('BLUBBERTHREAD_VISITED_' . $thread->getId(), $visitedDate);
+        }
+
+        return $this->getContentResponse($thread);
+    }
+
+    protected function validateResourceDocument($json)
+    {
+        if (self::arrayHas($json, 'data.attributes.visited-at')) {
+            $visitedAt = self::arrayGet($json, 'data.attributes.visited-at');
+            if (!self::isValidTimestamp($visitedAt)) {
+                return '`visited-at` is not an ISO 8601 timestamp.';
+            }
+        }
+    }
+}
diff --git a/lib/classes/JsonApi/Schemas/BlubberComment.php b/lib/classes/JsonApi/Schemas/BlubberComment.php
index 00770237b02..6c0ffe347b9 100644
--- a/lib/classes/JsonApi/Schemas/BlubberComment.php
+++ b/lib/classes/JsonApi/Schemas/BlubberComment.php
@@ -19,11 +19,15 @@ class BlubberComment extends SchemaProvider
 
     public function getAttributes($resource, ContextInterface $context): iterable
     {
+        $userId = $this->currentUser->id;
+
         $attributes = [
             # `network` VARCHAR(64) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
             'content' => $resource['content'],
             'content-html' => blubberReady($resource['content']),
 
+            'is-writable' => $resource->isWritable($userId),
+
             'mkdate' => date('c', $resource['mkdate']),
             'chdate' => date('c', $resource['chdate']),
         ];
diff --git a/lib/classes/JsonApi/Schemas/BlubberThread.php b/lib/classes/JsonApi/Schemas/BlubberThread.php
index 2a7f6605587..e959c05fcce 100644
--- a/lib/classes/JsonApi/Schemas/BlubberThread.php
+++ b/lib/classes/JsonApi/Schemas/BlubberThread.php
@@ -14,8 +14,6 @@ class BlubberThread extends SchemaProvider
     const REL_CONTEXT = 'context';
     const REL_MENTIONS = 'mentions';
 
-
-
     public function getId($resource): ?string
     {
         return $resource->id;
@@ -25,8 +23,18 @@ class BlubberThread extends SchemaProvider
     {
         $userId = $this->currentUser->id;
 
+        $contextInfo = null;
+        $contextTemplate = $resource->getContextTemplate();
+        if ($contextTemplate) {
+            $contextInfo = $contextTemplate->render();
+        }
+
         $attributes = [
+            'name' => $resource->getName(),
+
             'context-type' => $resource['context_type'],
+            'context-info' => $contextInfo,
+
             'content' => $resource['content'],
             'content-html' => formatReady($resource['content']),
 
@@ -35,7 +43,11 @@ class BlubberThread extends SchemaProvider
             'is-writable' => (bool) $resource->isWritable($userId),
 
             'is-visible-in-stream' => (bool) $resource->isVisibleInStream(),
+            'is-followed' => (bool) $resource->isFollowedByUser($userId),
+            'may-disable-notifications' => (bool) $resource->mayDisableNotifications($userId),
 
+            'latest-activity' => date('c', $resource->getLatestActivity()),
+            'visited-at' => date('c', $resource->getLastVisit($userId)),
             'mkdate' => date('c', $resource['mkdate']),
             'chdate' => date('c', $resource['chdate']),
         ];
@@ -51,16 +63,32 @@ class BlubberThread extends SchemaProvider
     public function getRelationships($resource, ContextInterface $context): iterable
     {
         $relationships = [];
-        $relationships = $this->getAuthorRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_AUTHOR));
+        $relationships = $this->getAuthorRelationship(
+            $relationships,
+            $resource,
+            $this->shouldInclude($context, self::REL_AUTHOR)
+        );
 
         $isPrimary = $context->getPosition()->getLevel() === 0;
         if (!$isPrimary) {
             return $relationships;
         }
 
-        $relationships = $this->getCommentsRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_COMMENTS));
-        $relationships = $this->getContextRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_CONTEXT));
-        $relationships = $this->getMentionsRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_MENTIONS));
+        $relationships = $this->getCommentsRelationship(
+            $relationships,
+            $resource,
+            $this->shouldInclude($context, self::REL_COMMENTS)
+        );
+        $relationships = $this->getContextRelationship(
+            $relationships,
+            $resource,
+            $this->shouldInclude($context, self::REL_CONTEXT)
+        );
+        $relationships = $this->getMentionsRelationship(
+            $relationships,
+            $resource,
+            $this->shouldInclude($context, self::REL_MENTIONS)
+        );
 
         return $relationships;
     }
@@ -78,6 +106,10 @@ class BlubberThread extends SchemaProvider
                 ],
                 self::RELATIONSHIP_DATA => $related,
             ];
+        } else {
+            $relationships[self::REL_AUTHOR] = [
+                self::RELATIONSHIP_DATA => null,
+            ];
         }
 
         return $relationships;
@@ -107,7 +139,12 @@ class BlubberThread extends SchemaProvider
     {
         $relationship = [
             self::RELATIONSHIP_LINKS => [
-                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_COMMENTS),
+                Link::RELATED => $this->getFactory()->createLink(
+                    true,
+                    $this->getSelfSubUrl($resource) . '/' . self::REL_COMMENTS,
+                    true,
+                    ['unseen-comments' => $resource->countUnseenComments($this->currentUser->id)]
+                ),
             ],
         ];
 
@@ -128,7 +165,8 @@ class BlubberThread extends SchemaProvider
         $related = $data = null;
 
         if ('course' === $resource['context_type']) {
-            if (!$course = \Course::find($resource['context_id'])) {
+            $course = \Course::find($resource['context_id']);
+            if (!$course) {
                 throw new InternalServerError('Inconsistent data in BlubberThread.');
             }
 
@@ -137,7 +175,8 @@ class BlubberThread extends SchemaProvider
         }
 
         if ('institute' === $resource['context_type']) {
-            if (!$institute = \Institute::find($resource['context_id'])) {
+            $institute = \Institute::find($resource['context_id']);
+            if (!$institute) {
                 throw new InternalServerError('Inconsistent data in BlubberThread.');
             }
 
@@ -157,4 +196,22 @@ class BlubberThread extends SchemaProvider
 
         return $relationships;
     }
+
+    /**
+     * @inheritdoc
+     */
+    public function hasResourceMeta($resource): bool
+    {
+        return true;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getResourceMeta($resource)
+    {
+        return [
+            'avatar' => $resource->getAvatar(),
+        ];
+    }
 }
diff --git a/lib/classes/JsonApi/Schemas/User.php b/lib/classes/JsonApi/Schemas/User.php
index 3e326652ed9..beaeecb7b77 100644
--- a/lib/classes/JsonApi/Schemas/User.php
+++ b/lib/classes/JsonApi/Schemas/User.php
@@ -2,6 +2,7 @@
 
 namespace JsonApi\Schemas;
 
+use JsonApi\Routes\Users\Authority as UsersAuthority;
 use Neomerx\JsonApi\Contracts\Factories\FactoryInterface;
 use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
 use Neomerx\JsonApi\Schema\Link;
@@ -12,6 +13,7 @@ class User extends SchemaProvider
 
     const REL_ACTIVITYSTREAM = 'activitystream';
     const REL_BLUBBER = 'blubber-threads';
+    const REL_BLUBBER_DEFAULT_THREAD = 'blubber-default-thread';
     const REL_CONFIG_VALUES = 'config-values';
     const REL_CONTACTS = 'contacts';
     const REL_COURSES = 'courses';
@@ -191,11 +193,26 @@ class User extends SchemaProvider
      */
     private function getBlubberRelationship(array $relationships, \User $user, $includeData)
     {
-        $relationships[self::REL_BLUBBER] = [
-            self::RELATIONSHIP_LINKS => [
-                Link::RELATED => $this->getRelationshipRelatedLink($user, self::REL_BLUBBER),
-            ],
-        ];
+        if (\Config::get()->BLUBBER_GLOBAL_MESSENGER_ACTIVATE) {
+            $relationships[self::REL_BLUBBER] = [
+                self::RELATIONSHIP_LINKS => [
+                    Link::RELATED => $this->getRelationshipRelatedLink($user, self::REL_BLUBBER),
+                ],
+            ];
+
+            if (UsersAuthority::canEditUser($this->currentUser, $user)) {
+                $threadId = $user->getConfiguration()->getValue('BLUBBER_DEFAULT_THREAD');
+                $thread = $includeData
+                    ? \BlubberThread::find($threadId)
+                    : \BlubberThread::build(['id' => $threadId], false);
+                $relationships[self::REL_BLUBBER_DEFAULT_THREAD] = [
+                    self::RELATIONSHIP_LINKS_SELF => true,
+                    self::RELATIONSHIP_LINKS => [
+                        Link::RELATED => $this->createLinkToResource($thread),
+                    ],
+                ];
+            }
+        }
 
         return $relationships;
     }
diff --git a/lib/classes/sidebar/BlubberThreadsWidget.php b/lib/classes/sidebar/BlubberThreadsWidget.php
deleted file mode 100644
index db80023fa45..00000000000
--- a/lib/classes/sidebar/BlubberThreadsWidget.php
+++ /dev/null
@@ -1,61 +0,0 @@
-<?php
-
-/**
- * @property BlubberThread[] $elements
- */
-class BlubberThreadsWidget extends SidebarWidget
-{
-    protected $active_thread = null;
-    protected $with_composer = false;
-
-    /**
-     * @param BlubberThread $thread
-     */
-    public function addThread($thread)
-    {
-        $this->elements[] = $thread;
-    }
-
-    public function setActive($thread_id)
-    {
-        $this->active_thread = $thread_id;
-    }
-
-    public function withComposer($with = true)
-    {
-        $this->with_composer = $with;
-    }
-
-    public function render($variables = [])
-    {
-        $template = $GLOBALS['template_factory']->open('blubber/threads-overview.php');
-        if (count($this->elements) > 30) {
-            array_pop($this->elements);
-            $template->more_down = true;
-        }
-
-        $json = [];
-        foreach ($this->elements as $thread) {
-            $unseen_comments = BlubberComment::countBySQL("thread_id = ? AND mkdate >= ?", [
-                $thread->getId(),
-                $thread->getLastVisit()
-            ]);
-
-            $json[] = [
-                'thread_id'       => $thread->getId(),
-                'avatar'          => $thread->getAvatar(),
-                'name'            => $thread->getName(),
-                'timestamp'       => (int) $thread->getLatestActivity(),
-                'mkdate'          => (int) $thread->mkdate,
-                'unseen_comments' => $unseen_comments,
-                'notifications'   => $thread->id === 'global' || ($thread->context_type === 'course' && !$GLOBALS['perm']->have_perm('admin')),
-                'followed'        => $thread->isFollowedByUser(),
-            ];
-        }
-
-        $template->threads = $this->elements;
-        $template->with_composer = $this->with_composer;
-        $template->json = $json;
-        return $template->render();
-    }
-}
diff --git a/lib/models/BlubberThread.php b/lib/models/BlubberThread.php
index d0f2f89ecd7..52465d2fe1f 100644
--- a/lib/models/BlubberThread.php
+++ b/lib/models/BlubberThread.php
@@ -1085,9 +1085,11 @@ class BlubberThread extends SimpleORMap implements PrivacyObject
     /**
      * Returns whether the notifications for this thread may be disabled.
      *
+     * @param string $user_id  optional; use this ID instead of $GLOBALS['user']->id
+     *
      * @return bool
      */
-    public function mayDisableNotifications(): bool
+    public function mayDisableNotifications(string $user_id = null): bool
     {
         // Notifications may always be disabled for global blubber stream
         if ($this->id === 'global') {
@@ -1101,6 +1103,25 @@ class BlubberThread extends SimpleORMap implements PrivacyObject
         }
 
         // Only users with permission below admin may disable the notifications.
-        return !$GLOBALS['perm']->have_perm('admin');
+        $user_id = $user_id ?? $GLOBALS['user']->id;
+
+        return !$GLOBALS['perm']->have_perm('admin', $user_id);
+    }
+
+    /**
+     * Count all unseen comments of this thread.
+     *
+     * @param string $user_id  optional; use this ID instead of $GLOBALS['user']->id
+     *
+     */
+    public function countUnseenComments(string $user_id = null): int
+    {
+        return \BlubberComment::countBySQL(
+            'thread_id = ? AND mkdate >= ?',
+            [
+                $this->getId(),
+                $this->getLastVisit($user_id ?? $GLOBALS['user']->id) ?: object_get_visit_threshold(),
+            ]
+        );
     }
 }
diff --git a/resources/assets/javascripts/chunk-loader.js b/resources/assets/javascripts/chunk-loader.js
index 59c052792f9..db613fdebc7 100644
--- a/resources/assets/javascripts/chunk-loader.js
+++ b/resources/assets/javascripts/chunk-loader.js
@@ -1,4 +1,4 @@
-STUDIP.loadScript = function (script_name) {
+export const loadScript = function (script_name) {
     return new Promise(function (resolve, reject) {
         let script = document.createElement('script');
         script.src = `${STUDIP.ASSETS_URL}${script_name}`;
@@ -8,7 +8,7 @@ STUDIP.loadScript = function (script_name) {
     });
 };
 
-STUDIP.loadChunk = (function () {
+export const loadChunk = (function () {
     var mathjax_promise = null;
 
     return function (chunk) {
diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js
index 70b78e7217a..5de07aa22e1 100644
--- a/resources/assets/javascripts/entry-base.js
+++ b/resources/assets/javascripts/entry-base.js
@@ -15,7 +15,6 @@ import "./jquery-bundle.js"
 
 import "./init.js"
 import "./bootstrap/responsive.js"
-import "./chunk-loader.js"
 import "./bootstrap/vue.js"
 
 import "./bootstrap/my-courses.js";
diff --git a/resources/assets/javascripts/init.js b/resources/assets/javascripts/init.js
index a37a466a52b..a7d6f05f96b 100644
--- a/resources/assets/javascripts/init.js
+++ b/resources/assets/javascripts/init.js
@@ -1,3 +1,4 @@
+import { loadChunk, loadScript, } from './chunk-loader.js';
 import Vue from './lib/studip-vue.js';
 
 import ActionMenu from './lib/actionmenu.js';
@@ -125,6 +126,8 @@ window.STUDIP = _.assign(window.STUDIP || {}, {
     JSONAPI,
     JSUpdater,
     Lightbox,
+    loadChunk,
+    loadScript,
     Markup,
     Members,
     Messages,
diff --git a/resources/assets/javascripts/lib/blubber.js b/resources/assets/javascripts/lib/blubber.js
index d1691637f9a..44d91bd9c10 100644
--- a/resources/assets/javascripts/lib/blubber.js
+++ b/resources/assets/javascripts/lib/blubber.js
@@ -1,171 +1,40 @@
-import { $gettext } from './gettext';
-
-
 const Blubber = {
-    App: null, //This app is not always available. The app is blubber with a widget and the threads next to it.
-    threads: [],
-    components: {
-        BlubberGlobalstream: () => import('../../../vue/components/BlubberGlobalstream.vue'),
-        BlubberPublicComposer: () => import('../../../vue/components/BlubberPublicComposer.vue'),
-        BlubberThread: () => import('../../../vue/components/BlubberThread.vue'),
-        BlubberThreadWidget: () => import('../../../vue/components/BlubberThreadWidget.vue'),
-    },
-    init () {
-        let components = STUDIP.Blubber.components;
-        if ($('#blubber-index, #messenger-course, .blubber_panel.vueinstance').length) {
-            STUDIP.JSUpdater.register('blubber', Blubber.updateState, Blubber.getParamsForPolling);
-
-            let panel_data = $('.blubber_panel').data();
-            STUDIP.Vue.load().then(({createApp}) => {
-                STUDIP.Blubber.App = createApp({
-                    el: '#content-wrapper',
-                    data() {
-                        return {
-                            threads: $('.blubber_threads_widget').data('threads_data'),
-                            thread_data: panel_data.thread_data,
-                            active_thread: panel_data.active_thread,
-                            threads_more_down: panel_data.threads_more_down,
-                            waiting: false,
-                            display_context_posting: 0
-                        };
-                    },
-                    methods: {
-                        changeActiveThread: function (thread_id) {
-                            this.waiting = true;
-                            let search = jQuery("form.sidebar-search input[name=search]").val();
-                            let parameters = search ? {data: {"search": search}} : {};
-                            STUDIP.api.GET(`blubber/threads/${thread_id}`, parameters).done((data) => {
-                                this.active_thread = thread_id;
-                                this.thread_data = data;
-                            }).always(() => {
-                                this.waiting = false;
-                            }).fail(() => {
-                                window.alert($gettext("Konnte die Konversation nicht laden. Probieren Sie es nachher erneut."));
-                            });
-                            for (let i in this.threads) {
-                                if (this.threads[i].thread_id === thread_id) {
-                                    this.threads[i].unseen_comments = 0;
-                                }
-                            }
-                        }
-                    },
-                    components,
-                });
-            });
-
-            $(document).on('submit', 'form.sidebar-search', function (event) {
-                this.waiting = true;
-                let search = jQuery("form.sidebar-search input[name=search]").val();
-                if ($('#messenger-course').length === 0) {
-                    STUDIP.api.GET(`blubber/threads`, {data: {"search": search}}).done((data) => {
-                        STUDIP.Blubber.App.threads = data.threads;
-                        STUDIP.Blubber.App.threads_more_down = data.more_down;
-                        $('.blubber_thread_widget')[0].__vue__.display_more_down = data.more_down;
-                    }).always(() => {
-                        this.waiting = false;
-                    }).fail(() => {
-                        window.alert($gettext("Konnte die Suche nicht ausführen. Probieren Sie es nachher erneut."));
-                    });
-                }
-                let parameters = search ? {"search": search} : {"modifier": "olderthan"};
-                STUDIP.api.GET(`blubber/threads/` + STUDIP.Blubber.App.active_thread + `/comments`, {data: parameters}).done((data) => {
-                    STUDIP.Blubber.App.thread_data.comments = data.comments;
-                    STUDIP.Blubber.App.thread_data.more_up = data.more_up;
-                    STUDIP.Blubber.App.thread_data.more_down = data.more_down;
-                    $('.blubber_thread')[0].__vue__.scrollDown();
-                }).always(() => {
-                    this.waiting = false;
-                }).fail(() => {
-                    window.alert($gettext("Konnte die Suche nicht ausführen. Probieren Sie es nachher erneut."));
-                });
-                event.preventDefault();
-                return false;
-            });
-            jQuery('#blubber-index, #messenger-course').on("click", 'a.blubber_hashtag', function (event) {
-                let tag = jQuery(this).closest("a").data("tag");
-                jQuery("form.sidebar-search input[name=search]").val("#" + tag);
-                jQuery("form.sidebar-search").trigger("submit");
-                event.preventDefault();
-                return false;
-            });
-        }
-
-        $(document).on('dialog-open', function() {
-            $('.studip-dialog .blubber_panel').each(function () {
-                STUDIP.JSUpdater.register('blubber', Blubber.updateState, Blubber.getParamsForPolling);
-
-                let panel_data = $(this).data();
-                STUDIP.Vue.load().then(({createApp}) => {
-                    createApp({
-                        el: this,
-                        data () {
-                            return {
-                                threads: panel_data.threads_data,
-                                thread_data: panel_data.thread_data,
-                                active_thread: panel_data.active_thread,
-                                threads_more_down: panel_data.threads_more_down,
-                                waiting: false,
-                                display_context_posting: 0
-                            };
-                        },
-                        components,
-                    });
-                });
-            });
-        });
-    },
-    updateState(datagram) {
-        for (const [method, data] of Object.entries(datagram)) {
-            if (method in Blubber) {
-                Blubber[method](data);
+    init() {
+        const blubberPage = document.querySelector('#blubber-index, #messenger-course, .blubber_panel.vueinstance');
+        if (blubberPage !== null) {
+            const blubberPanel = document.querySelector('.blubber_panel');
+            if (blubberPanel !== null) {
+                connectBlubber(blubberPanel, 'BlubberCommunityPage');
             }
         }
-    },
-    getParamsForPolling () {
-        const data = {
-            threads: [],
-        };
-        $('.blubber_thread').each(function () {
-            data.threads.push(this.__vue__._props.thread_data.thread_posting.thread_id);
-        });
 
-        return data;
-    },
-    addNewComments (blubberdata) {
-        $('.blubber_thread').each(function () {
-            for (let thread_id in blubberdata) {
-                if (this.__vue__._props.thread_data.thread_posting.thread_id === thread_id) {
-                    this.__vue__.addComments(blubberdata[thread_id], true);
-                    this.__vue__.scrollDown();
-                }
+        $(document).on('dialog-open', function (event, { dialog }) {
+            const blubberPanel = dialog.querySelector('.blubber_panel');
+            if (blubberPanel !== null) {
+                connectBlubber(blubberPanel, 'BlubberDialogPanel');
             }
         });
-    },
-    removeDeletedComments: function (comment_ids) {
-        $('.blubber_thread').each(function () {
-            this.__vue__.removeDeletedComments(comment_ids);
-        });
-    },
-    updateThreadWidget (threaddata) {
-        for (let i in threaddata) {
-            let exists = false;
-            for (let k in STUDIP.Blubber.App.threads) {
-                if (STUDIP.Blubber.App.threads[k].thread_id == threaddata[i].thread_id) {
-                    exists = true;
-                    STUDIP.Blubber.App.threads[k].name = threaddata[i].name;
-                    STUDIP.Blubber.App.threads[k].timestamp = threaddata[i].timestamp;
-                    STUDIP.Blubber.App.threads[k].avatar = threaddata[i].avatar;
+
+        function connectBlubber(blubberPanel, componentName) {
+            return Promise.all([window.STUDIP.Vue.load(), Blubber.plugin()]).then(
+                ([{ Vue, createApp, store }, BlubberPlugin]) => {
+                    Vue.use(BlubberPlugin, { store });
+                    const { initialThreadId, search } = blubberPanel.dataset;
+                    return createApp({
+                        el: blubberPanel,
+                        render: (h) => h(Vue.component(componentName), { props: { initialThreadId, search } }),
+                    });
                 }
-            }
-            if (!exists) {
-                STUDIP.Blubber.App.threads.push(threaddata[i]);
-            }
+            );
         }
     },
-    refreshThread (data) {
-        STUDIP.Blubber.App.changeActiveThread(data.thread_id);
+    plugin() {
+        return import('@/vue/plugins/blubber.js').then(({ BlubberPlugin }) => BlubberPlugin);
+    },
+    refreshThread(data) {
+        STUDIP.eventBus.emit('studip:select-blubber-thread', data.thread_id);
     },
-    followunfollow (thread_id, follow) {
+    followunfollow(thread_id, follow) {
         const elements = $(`.blubber_panel .followunfollow[data-thread_id="${thread_id}"]`);
         if (follow === undefined) {
             follow = elements.hasClass('unfollowed');
@@ -176,51 +45,56 @@ const Blubber = {
             ? STUDIP.api.POST(`blubber/threads/${thread_id}/follow`)
             : STUDIP.api.DELETE(`blubber/threads/${thread_id}/follow`);
 
-        return promise.then(() => {
-            elements.toggleClass('unfollowed', !follow);
-            return follow;
-        }).always(() => {
-            elements.removeClass('loading');
-        }).promise();
+        return promise
+            .then(() => {
+                elements.toggleClass('unfollowed', !follow);
+                return follow;
+            })
+            .always(() => {
+                elements.removeClass('loading');
+            })
+            .promise();
     },
     Composer: {
         vue: null,
-        init () {
-            STUDIP.Vue.load().then(({createApp}) => {
-                let components = STUDIP.Blubber.components;
-                return createApp({
-                    el: '#blubber_contact_ids',
-                    data () {
-                        return {
-                            users: []
-                        };
-                    },
-                    methods: {
-                        addUser: function (user_id, name) {
-                            this.users.push({
-                                user_id: user_id,
-                                name: name
-                            });
+        init() {
+            STUDIP.Vue.load()
+                .then(({ createApp }) => {
+                    let components = STUDIP.Blubber.components;
+                    return createApp({
+                        el: '#blubber_contact_ids',
+                        data() {
+                            return {
+                                users: [],
+                            };
                         },
-                        removeUser: function (event) {
-                            let user_id = $(event.target).closest('li').find('input').val();
-                            for (let i in this.users) {
-                                if (this.users[i].user_id === user_id) {
-                                    this.$delete(this.users, i);
+                        methods: {
+                            addUser: function (user_id, name) {
+                                this.users.push({
+                                    user_id: user_id,
+                                    name: name,
+                                });
+                            },
+                            removeUser: function (event) {
+                                let user_id = $(event.target).closest('li').find('input').val();
+                                for (let i in this.users) {
+                                    if (this.users[i].user_id === user_id) {
+                                        this.$delete(this.users, i);
+                                    }
                                 }
-                            }
+                            },
+                            clearUsers: function () {
+                                this.users = [];
+                            },
                         },
-                        clearUsers: function () {
-                            this.users = [];
-                        }
-                    },
-                    components,
+                        components,
+                    });
+                })
+                .then((app) => {
+                    STUDIP.Blubber.Composer.vue = app;
                 });
-            }).then((app) => {
-                STUDIP.Blubber.Composer.vue = app;
-            });
-        }
-    }
+        },
+    },
 };
 
 export default Blubber;
diff --git a/resources/assets/javascripts/lib/jsupdater.js b/resources/assets/javascripts/lib/jsupdater.js
index fad92e21df7..7888f29218b 100644
--- a/resources/assets/javascripts/lib/jsupdater.js
+++ b/resources/assets/javascripts/lib/jsupdater.js
@@ -219,6 +219,12 @@ const JSUpdater = {
         active = false;
     },
 
+    // Returns true if there is already a registered handler for this index,
+    // false otherwise
+    isRegistered(index) {
+        return index in registeredHandlers;
+    },
+
     // Registers a new handler by an index, a callback and an optional data
     // object or function
     register(index, callback, data = null, interval = 0) {
diff --git a/resources/assets/stylesheets/scss/blubber.scss b/resources/assets/stylesheets/scss/blubber.scss
index 1b0e9e7fc94..6c9e7ff0a6d 100644
--- a/resources/assets/stylesheets/scss/blubber.scss
+++ b/resources/assets/stylesheets/scss/blubber.scss
@@ -286,6 +286,8 @@
         justify-content: space-around;
         align-items: center;
 
+        transition: all 0.5s ease-out;
+
         > textarea {
             border: 1px solid $content-color-40;
             background-color: $white;
diff --git a/resources/vue/components/BlubberGlobalstream.vue b/resources/vue/components/BlubberGlobalstream.vue
deleted file mode 100644
index 2236e1fba96..00000000000
--- a/resources/vue/components/BlubberGlobalstream.vue
+++ /dev/null
@@ -1,130 +0,0 @@
-<template>
-    <div class="blubber_globalstream">
-        <div class="scrollable_area" v-scroll>
-            <blubber-public-composer></blubber-public-composer>
-            <ol class="postings" aria-live="polite">
-                <li class="more" v-if="streamData.more_up">
-                    <studip-asset-img file="loading-indicator.svg" width="20"></studip-asset-img>
-                </li>
-
-                <li :class="blubber.class"
-                    v-for="blubber in sortedPostings"
-                    :data-thread_id="blubber.thread_id"
-                    :key="blubber.thread_id">
-                    <div class="thread_posting" v-if="blubber.html">
-                        <div class="contextinfo">
-                            <studip-date-time :timestamp="blubber.mkdate" :relative="true"></studip-date-time>
-                            <div>{{ blubber.user_name }}</div>
-                            <div class="avatar" :style="{ backgroundImage: 'url(' + blubber.avatar + ')' }"></div>
-                        </div>
-                        <div class="content" v-html="blubber.html"></div>
-                        <a class="link_to_comments"
-                           :href="link(blubber.thread_id)"
-                           @click.prevent="changeActiveThread" v-translate>Zur Diskussion</a>
-                    </div>
-                </li>
-
-                <li class="more" v-if="more_down">
-                    <studip-asset-img file="loading-indicator.svg" width="20"></studip-asset-img>
-                </li>
-            </ol>
-        </div>
-    </div>
-</template>
-
-<script>
-    export default {
-        name: 'blubber-globalstream',
-        data: function () {
-            return {
-                already_loading_down: 0,
-                streamData: this.stream_data,
-            };
-        },
-        props: ['stream_data', 'more_down'],
-        methods: {
-            changeActiveThread: function (event) {
-                let li = $(event.target).closest('li');
-                this.$root.changeActiveThread(li.data('thread_id'));
-            },
-            link: function (thread_id) {
-                return STUDIP.URLHelper.getURL(`dispatch.php/blubber/index/${thread_id}`);
-            },
-            addPosting: function (posting) {
-                let exists = false;
-                for (let i in this.stream_data) {
-                    if (this.streamData[i].thread_id === posting.thread_id) {
-                        exists = true;
-                        return;
-                    }
-                }
-                if (!exists) {
-                    posting.class = posting.class + " new";
-                    this.streamData.push(posting);
-                    this.$nextTick(() => {
-                        STUDIP.Markup.element($(this.$el).find(`.postings > li[data-thread_id="${posting.thread_id}"]`));
-                    });
-                }
-            }
-        },
-        mounted () { //when everything is initialized
-            this.$nextTick(function () {
-                $(this.$el).find('.postings .content').each(function () {
-                    STUDIP.Markup.element(this);
-                });
-            });
-        },
-        computed: {
-            sortedPostings() {
-                return [...this.streamData].sort((a, b) => b.mkdate - a.mkdate);
-            }
-        },
-        directives: {
-            scroll: {
-                // directive definition
-                inserted: function (el) {
-                    let stream = $(el).closest(".blubber_globalstream")[0].__vue__;
-                    $(el).on('scroll', function (event) {
-                        let top = $(el).scrollTop();
-                        let height = $(el).find(".postings").height();
-
-                        $(el).toggleClass('scrolled', top > 0);
-
-                        if (stream.more_down && (top > $(el).find(".postings").height() - 1000)
-                                && !stream.already_loading_down) {
-                            stream.already_loading_down = 1;
-
-                            let earliest_mkdate = null;
-                            for (let i in stream.streamData) {
-                                if ((earliest_mkdate === null) || stream.streamData[i].mkdate < earliest_mkdate) {
-                                    earliest_mkdate = stream.streamData[i].mkdate;
-                                }
-                            }
-                            //load older comments
-                            $.ajax({
-                                url: STUDIP.ABSOLUTE_URI_STUDIP + "api.php/blubber/threads/global",
-                                type: "get",
-                                dataType: "json",
-                                data: {
-                                    modifier: "olderthan",
-                                    timestamp: earliest_mkdate,
-                                    limit: 30
-                                },
-                                success: function (data) {
-                                    for (let i in data.postings) {
-                                        stream.addPosting(data.postings[i]);
-                                    }
-                                    stream.more_down = data.more_down;
-                                },
-                                complete: function () {
-                                    stream.already_loading_down = 0;
-                                }
-                            });
-
-                        }
-                    });
-                }
-            }
-        }
-    }
-</script>
diff --git a/resources/vue/components/BlubberPublicComposer.vue b/resources/vue/components/BlubberPublicComposer.vue
deleted file mode 100644
index 15fce7ce31a..00000000000
--- a/resources/vue/components/BlubberPublicComposer.vue
+++ /dev/null
@@ -1,98 +0,0 @@
-<template>
-    <div class="writer">
-        <studip-icon shape="blubber" size="30" role="info"></studip-icon>
-        <textarea :placeholder="$gettext('Schreib was, frag was. Enter zum Abschicken.')"
-                  @keyup.enter.exact="submit"
-                  @keyup="saveCommentToSession" @change="saveCommentToSession"></textarea>
-        <label class="upload" :title="$gettext('Datei hochladen')">
-            <input type="file" multiple style="display: none;" @change="upload">
-            <studip-icon shape="upload" size="30"></studip-icon>
-        </label>
-    </div>
-</template>
-<script>
-    export default {
-        name: 'blubber-public-composer',
-        methods: {
-            submit (text) {
-                if (!text || typeof text !== "string") {
-                    text = $(this.$el).find("textarea").val();
-                    $(this.$el).find("textarea").val("");
-                    sessionStorage.removeItem(
-                        'BlubberMemory-Writer-Public'
-                    );
-                }
-                if (!text.trim()) {
-                    return false;
-                }
-                let thread = this;
-
-                //AJAX-Request ...
-                STUDIP.api.POST(`blubber/threads`, {
-                    data: {
-                        content: text
-                    }
-                }).done((data) => {
-                    this.$parent.addPosting(data.thread_posting);
-                });
-            },
-            saveCommentToSession (event) {
-                let value = event.target.value;
-                sessionStorage.setItem(
-                    `BlubberMemory-Writer-Public`,
-                    value
-                );
-            },
-            upload (event) {
-                let files = typeof event.dataTransfer !== 'undefined'
-                    ? event.dataTransfer.files // file drop
-                    : event.target.files; // upload button
-                let writer = this;
-                let data = new FormData();
-                for (let i in files) {
-                    if (files[i].size > 0) {
-                        data.append(`file_${i}`, files[i], files[i].name.normalize());
-                    }
-                }
-
-                let request = new XMLHttpRequest();
-                request.open('POST', `${STUDIP.ABSOLUTE_URI_STUDIP}dispatch.php/blubber/upload_files`);
-                request.upload.addEventListener('progress', (event) => {
-                    var percent = 0;
-                    var position = event.loaded || event.position;
-                    var total = event.total;
-                    if (event.lengthComputable) {
-                        percent = Math.ceil(position / total * 100);
-                    }
-                    //Set progress
-                    $(writer.$el).css('background-size', `${percent}% 100%`);
-                });
-                request.addEventListener('load', function (event) {
-                    let output = JSON.parse(this.response);
-                    $(writer.$el).find("textarea").val(
-                        $(writer.$el).find("textarea").val()
-                        + " "
-                        + output.inserts.join(" ")
-                    );
-                });
-                request.addEventListener('loadend', function (event) {
-                    $(writer.$el).css('background-size', '0% 100%');
-                });
-                request.send(data);
-            }
-        },
-        mounted () { //when everything is initialized
-            this.$nextTick(function () {
-                $(this.$el).find('textarea').autoResize({
-                    animateDuration: 0,
-                    // More extra space:
-                    extraSpace: 1
-                });
-                let memory = sessionStorage.getItem(`BlubberMemory-Writer-Public`);
-                if (memory) {
-                    $(this.$el).find('textarea').val(memory);
-                }
-            });
-        }
-    }
-</script>
diff --git a/resources/vue/components/BlubberThread.vue b/resources/vue/components/BlubberThread.vue
deleted file mode 100644
index dab56cfea65..00000000000
--- a/resources/vue/components/BlubberThread.vue
+++ /dev/null
@@ -1,526 +0,0 @@
-<template>
-    <div class="blubber_thread" :class="{dragover: dragging}"
-         :id="'blubberthread_' + threadData.thread_posting.thread_id"
-         @dragover.prevent="dragover" @dragleave.prevent="dragleave"
-         @drop.prevent="upload">
-        <div class="hidden-medium-up context_info" v-if="threadData.notifications">
-            <a href="#"
-               @click.prevent="toggleFollow()"
-               class="followunfollow"
-               :class="{unfollowed: !threadData.followed}"
-               :title="$gettext('Benachrichtigungen für diese Konversation abstellen.')"
-               :data-thread_id="thread_data.thread_posting.thread_id">
-                <StudipIcon shape="decline" :size="20" class="follow text-bottom"></StudipIcon>
-                <StudipIcon shape="notification2" :size="20" class="unfollow text-bottom"></StudipIcon>
-                {{ $gettext('Benachrichtigungen aktiviert') }}
-            </a>
-        </div>
-        <div class="scrollable_area" v-scroll>
-            <div class="all_content">
-                <div class="thread_posting" v-if="hasContent(threadData.thread_posting.content)">
-                    <div class="contextinfo">
-                        <studip-date-time :timestamp="threadData.thread_posting.mkdate" :relative="true"></studip-date-time>
-                        <a :href="getUserProfileURL(threadData.thread_posting.user_id, threadData.thread_posting.user_username)">{{ threadData.thread_posting.user_name }}</a>
-                        <a :href="getUserProfileURL(threadData.thread_posting.user_id, threadData.thread_posting.user_username)" class="avatar" :style="{ backgroundImage: 'url(' + threadData.thread_posting.avatar + ')' }"></a>
-                    </div>
-                    <div class="content" v-html="threadData.thread_posting.html"></div>
-                    <div class="link_to_comments"></div>
-                </div>
-
-                <div v-if="!hasContent(threadData.thread_posting.content) && !threadData.comments.length" class="empty_blubber_background">
-                    <div v-translate>Starte die Konversation jetzt!</div>
-                </div>
-
-                <ol class="comments" aria-live="polite">
-
-                    <li class="more" v-if="threadData.more_up">
-                        <studip-asset-img file="loading-indicator.svg" width="20"></studip-asset-img>
-                    </li>
-
-                    <li :class="comment.class"
-                        v-for="comment in sortedComments"
-                        :data-comment_id="comment.comment_id"
-                        :key="comment.comment_id">
-                        <a :href="getUserProfileURL(comment.user_id, comment.user_username)" class="avatar" :title="comment.user_name" :style="{ backgroundImage: 'url(' + comment.avatar + ')' }"></a>
-                        <div class="content">
-                            <a :href="getUserProfileURL(comment.user_id, comment.user_username)" class="name">{{ comment.user_name }}</a>
-                            <div v-html="comment.html" class="html"></div>
-                            <textarea class="edit"
-                                      v-html="comment.content"
-                                      @keydown.enter.exact="saveComment"
-                                      @keyup.escape.exact="editComment"></textarea>
-                        </div>
-                        <div class="time">
-                            <studip-date-time :timestamp="comment.mkdate" :relative="true"></studip-date-time>
-                            <a href="" v-if="comment.writable" @click.prevent.stop="editComment" class="edit_comment" :title="$gettext('Bearbeiten.')">
-                                <studip-icon shape="edit" size="14" role="inactive"></studip-icon>
-                            </a>
-                            <a href="" @click.prevent="answerComment" class="answer_comment" :title="$gettext('Hierauf antworten.')">
-                                <studip-icon shape="export" size="14" role="inactive"></studip-icon>
-                            </a>
-                        </div>
-                    </li>
-
-                    <li class="more" v-if="threadData.more_down">
-                        <studip-asset-img file="loading-indicator.svg" width="20"></studip-asset-img>
-                    </li>
-
-                </ol>
-            </div>
-        </div>
-        <div class="writer" v-if="threadData.thread_posting.commentable">
-            <studip-icon shape="blubber" size="30" role="info"></studip-icon>
-            <textarea :placeholder="writerTextareaPlaceholder"
-                      @keyup.enter.exact="submit"
-                      @keyup.up.exact="editPreviousComment"
-                      @keyup="saveCommentToSession" @change="saveCommentToSession"></textarea>
-            <a class="send" @click="submit" :title="$gettext('Abschicken')">
-                <studip-icon shape="arr_2up" size="30"></studip-icon>
-            </a>
-            <label class="upload" :title="$gettext('Datei hochladen')" tabindex="0"
-                   @keydown="simulateClick" ref="blubber_upload_file_label">
-                <input type="file" multiple style="display: none;" @change="upload">
-                <studip-icon shape="upload" size="30"></studip-icon>
-            </label>
-        </div>
-
-        <MountingPortal v-if="hasThreadsWidget" mountTo="#blubber-threads-widget" name="blubber-threads-widget">
-            <blubber-thread-widget
-                :threads="$root.threads"
-                :active_thread="$root.active_thread"
-                :more_down="$root.threads_more_down"></blubber-thread-widget>
-        </MountingPortal>
-    </div>
-</template>
-
-<script>
-    import BlubberThreadWidget from "./BlubberThreadWidget.vue";
-
-    export default {
-        name: 'blubber-thread',
-        components: { BlubberThreadWidget },
-        data: function () {
-            return {
-                already_loading_up: 0,
-                already_loading_down: 0,
-                dragging: false,
-                threadData: this.thread_data
-            };
-        },
-        props: ['thread_data'],
-        methods: {
-            submit (text) {
-                if (!text || typeof text !== "string") {
-                    text = $(this.$el).find(".writer textarea").val();
-                    $(this.$el).find(".writer textarea").val("");
-                    if (this.threadData.thread_posting.thread_id) {
-                        sessionStorage.removeItem(
-                            'BlubberMemory-Writer-' + this.threadData.thread_posting.thread_id
-                        );
-                    }
-                }
-                if (!text.trim()) {
-                    return false;
-                }
-                let formatted_text = text.replace(/\n/g, "<br>");
-                let comment = {
-                    comment_id: Math.random().toString(36),
-                    avatar: '',
-                    html: formatted_text,
-                    content: text,
-                    mkdate:  Math.floor(Date.now() / 1000),
-                    name: 'Nobody',
-                    class: 'mine new',
-                    writable: 1
-                };
-                this.addComment(comment);
-                let thread = this;
-
-                //AJAX-Request ...
-                STUDIP.api.POST(`blubber/threads/${this.threadData.thread_posting.thread_id}/comments`, {
-                    data: {
-                        content: text
-                    }
-                }).then(data => {
-                    // Check following state
-                    if (this.threadData.notifications) {
-                        STUDIP.api.GET(`blubber/threads/${this.threadData.thread_posting.thread_id}/follow`).then(followed => {
-                            jQuery('.followunfollow').toggleClass('unfollowed', !followed);
-                        });
-                    }
-                    return data;
-                }).done(data => {
-                    comment.comment_id = data.comment_id;
-                    comment.avatar = data.avatar;
-                    comment.user_name = data.user_name;
-                    comment.mkdate = data.mkdate;
-                    comment.html = data.html;
-                    comment.class = data.class;
-
-                    thread.$nextTick(() => {
-                        STUDIP.Markup.element($(thread.$el).find(`.comments > li[data-comment_id="${data.comment_id}"]`));
-                    });
-                });
-
-                this.$nextTick(() => {
-                    // DOM updated
-                    this.scrollDown();
-                });
-            },
-            saveCommentToSession (event) {
-                let value = event.target.value;
-                if (this.threadData.thread_posting.thread_id) {
-                    sessionStorage.setItem(
-                        `BlubberMemory-Writer-${this.threadData.thread_posting.thread_id}`,
-                        value
-                    );
-                }
-                $(this.$el).find('.writer').toggleClass(
-                    'filled',
-                    value.trim() !== ''
-                );
-            },
-            scrollDown () {
-                this.$nextTick(function () {
-                    let element = this.$el;
-
-                    let scroll = () => {
-                        $(element).find('.scrollable_area').scrollTo(
-                            $(element).find('.scrollable_area .all_content').height()
-                        );
-                    };
-
-                    $(element).find('.scrollable_area img').on('load', scroll);
-                    scroll();
-                });
-            },
-            addComments (comments, new_ones) {
-                comments.forEach((comment) => {
-                    if (new_ones) {
-                        comment.class += ' new';
-                    }
-                    this.addComment(comment);
-                });
-            },
-            addComment (comment) {
-                this.$nextTick(() => {
-                    STUDIP.Markup.element($(this.$el).find(`.comments > li[data-comment_id="${comment.comment_id}"]`));
-                });
-                for (let i in this.threadData.comments) {
-                    if (this.threadData.comments[i].comment_id === comment.comment_id) {
-                        this.threadData.comments[i].content = comment.content;
-                        this.threadData.comments[i].html = comment.html;
-                        return;
-                    }
-                }
-                this.threadData.comments.push(comment);
-            },
-            removeComment (comment_id) {
-                this.threadData.comments.forEach((comment, i) => {
-                    if (comment.comment_id === comment_id) {
-                        this.$delete(this.threadData.comments, i);
-                    }
-                });
-            },
-            upload (event) {
-                const viaDragAndDrop = event.dataTransfer !== undefined;
-
-                if (viaDragAndDrop && !event.dataTransfer.types.includes('Files')) {
-                    return;
-                }
-
-                let files = viaDragAndDrop
-                    ? event.dataTransfer.files // file drop
-                    : event.target.files; // upload button
-                let thread = this;
-                let data = new FormData();
-                for (let i in files) {
-                    if (files[i].size > 0) {
-                        data.append(`file_${i}`, files[i], files[i].name.normalize());
-                    }
-                }
-
-                var request = new XMLHttpRequest();
-                request.open('POST', `${STUDIP.ABSOLUTE_URI_STUDIP}dispatch.php/blubber/upload_files`);
-                request.upload.addEventListener('progress', (event) => {
-                    var percent = 0;
-                    var position = event.loaded || event.position;
-                    var total = event.total;
-                    if (event.lengthComputable) {
-                        percent = Math.ceil(position / total * 100);
-                    }
-                    //Set progress
-                    $(thread.$el).find('.writer').css('background-size', `${percent}% 100%`);
-                });
-                request.addEventListener('load', function (event) {
-                    let output = JSON.parse(this.response);
-                    thread.submit(output.inserts.join(" "));
-                });
-                request.addEventListener('loadend', function (event) {
-                    $(thread.$el).find('.writer').css('background-size', '0% 100%');
-                });
-                request.send(data);
-
-                this.dragleave();
-            },
-            dragover (event) {
-                this.dragging = event.dataTransfer.types.includes('Files');
-            },
-            dragleave (event) {
-                this.dragging = false;
-            },
-            getUserProfileURL (user_id, username) {
-                if (username) {
-                    return STUDIP.URLHelper.getURL('dispatch.php/profile', {
-                        username: username
-                    });
-                } else {
-                    return STUDIP.URLHelper.getURL('dispatch.php/profile/extern/' + user_id);
-                }
-            },
-            editComment (event) {
-                let li;
-                if (typeof event === 'string') {
-                    let comment_id = event;
-                    li = $(this.$el).find(`.comments > li[data-comment_id="${comment_id}"]`);
-                } else {
-                    li = $(event.target).closest('li[data-comment_id]');
-                    let comment_id = $(event.target).closest('li[data-comment_id]').data('comment_id');
-                }
-                li.find('.content').toggleClass('editing');
-                let textarea = li.find('.content textarea').last()[0];
-                textarea.focus();
-                textarea.setSelectionRange(textarea.value.length, textarea.value.length);
-                li.find('.content textarea:not(.auto-resizable)').addClass('auto-resizable').autoResize({
-                    animateDuration: 0
-                });
-            },
-            answerComment (event) {
-                let li;
-                if (typeof event === 'string') {
-                    let comment_id = event;
-                    li = $(this.$el).find(`.comments > li[data-comment_id="${comment_id}"]`);
-                } else {
-                    li = $(event.target).closest('li[data-comment_id]');
-                    let comment_id = $(event.target).closest('li[data-comment_id]').data('comment_id');
-                }
-                let comment_id = $(li).data('comment_id');
-                let comment_data = null;
-                this.threadData.comments.forEach((comment, i) => {
-                    if (comment.comment_id === comment_id) {
-                        comment_data = comment;
-                    }
-                });
-                if (comment_data) {
-                    let quote = '[quote=' + comment_data.user_name + ']' + (comment_data.content.replace(/\[quote[^\]]*\].*\[\/quote\]/g, '')).trim() + "[/quote]\n";
-                    $(this.$el).find('.writer textarea').val(quote);
-                    let textarea = $(this.$el).find('.writer textarea').last()[0];
-                    textarea.focus();
-                    textarea.setSelectionRange(textarea.value.length, textarea.value.length);
-                }
-            },
-            saveComment (event) {
-                let thread = this;
-                let li = $(event.target).closest('li[data-comment_id]');
-                let comment_id = li.data('comment_id');
-                let content = li.find('textarea').val();
-
-                thread.threadData.comments.forEach((comment) => {
-                    if (comment.comment_id === comment_id) {
-                        comment.html = content;
-                    }
-                });
-
-                li.find('.content').removeClass('editing');
-
-                STUDIP.api.PUT(`blubber/threads/${this.threadData.thread_posting.thread_id}/comments/${comment_id}`, {
-                    data: {
-                        content: content
-                    },
-                }).done((output) => {
-                    if (this.hasContent(output.content)) {
-                        thread.threadData.comments.forEach((comment) => {
-                            if (comment.comment_id === comment_id) {
-                                comment.html = output.html;
-                                comment.content = output.content;
-
-                                thread.$nextTick(() => {
-                                    STUDIP.Markup.element($(thread.$el).find(`.comments > li[data-comment_id="${comment_id}"]`));
-                                });
-                            }
-                        });
-                    } else {
-                        thread.removeComment(comment_id);
-                    }
-                    $(thread.$el).find('.writer textarea').focus();
-                });
-            },
-            removeDeletedComments: function (comment_ids) {
-                for (let i in comment_ids) {
-                    this.removeComment(comment_ids[i]);
-                }
-            },
-            editPreviousComment () {
-                if (!$(this.$el).find('.writer textarea').val().trim()) {
-                    let comment = $(this.$el).find('.comments li.mine').last();
-                    if (comment.length > 0) {
-                        this.editComment(comment.data('comment_id'));
-                    }
-                }
-            },
-            toggleFollow () {
-                STUDIP.Blubber.followunfollow(
-                    this.threadData.thread_posting.thread_id,
-                    !this.threadData.followed
-                ).done(state => {
-                    this.threadData.followed = state;
-                });
-            },
-            hasContent (input) {
-                return input && input.trim().length > 0;
-            },
-            simulateClick (event) {
-                if (event.code == "Enter") {
-                    //The enter key has been pressed.
-                    this.$refs.blubber_upload_file_label.click();
-                }
-            }
-        },
-        directives: {
-            scroll: {
-                // directive definition
-                inserted: function (el) {
-                    let thread = $(el).closest('.blubber_thread')[0].__vue__;
-
-                    $(el).on('scroll', (event) => {
-                        let top = $(el).scrollTop();
-                        let height = $(el).find('.all_content').height();
-
-                        $(el).toggleClass('scrolled', top > 0);
-
-                        thread.$root.display_context_posting = top >= $(el).find('.all_content .thread_posting').height()
-                            ? 1
-                            : 0;
-                        if (thread.threadData.more_up && top < 1000 && !thread.already_loading_up) {
-                            thread.already_loading_up = 1;
-
-                            let earliest_mkdate = thread.threadData.comments.reduce((min, comment) => {
-                                return min === null ? comment.mkdate : Math.min(min, comment.mkdate);
-                            }, null);
-
-                            //load older comments
-                            STUDIP.api.GET(`blubber/threads/${thread.threadData.thread_posting.thread_id}/comments`, {
-                                data: {
-                                    modifier: 'olderthan',
-                                    timestamp: earliest_mkdate,
-                                    limit: 50
-                                }
-                            }).done((data) => {
-                                top = $(el).scrollTop();
-                                thread.addComments(data.comments, false);
-                                thread.threadData.more_up = data.more_up;
-                                thread.$nextTick(function () {
-                                    //scroll to the position where we were:
-                                    let new_height = $(el).find(".all_content").height();
-                                    let new_scroll_top = new_height - height + top;
-                                    $(el).scrollTo(
-                                        new_scroll_top
-                                    );
-                                });
-                            }).done(() => {
-                                thread.already_loading_up = 0;
-                            });
-                        }
-
-                        if (thread.threadData.more_down && (top > $(thread).find(".scrollable_area .all_content").height() - 1000) && !thread.already_loading_down) {
-                            thread.already_loading_down = 1;
-
-                            let latest_mkdate = thread.threadData.comments.reduce((max, comment) => {
-                                return Math.max(max, comment.mkdate);
-                            }, null);
-
-                            //load newer comments
-                            STUDIP.api.GET(`blubber/threads/${thread.threadData.thread_posting.thread_id}/comments`, {
-                                data: {
-                                    modifier: 'newerthan',
-                                    timestamp: latest_mkdate,
-                                    limit: 50
-                                }
-                            }).done((data) => {
-                                thread.addComments(data.comments, false);
-                                thread.threadData.more_down = data.more_down;
-                            }).always(() => {
-                                thread.already_loading_down = 0;
-                            });
-                        }
-                    });
-                }
-            }
-        },
-        mounted () { //when everything is initialized
-            this.$nextTick(function () {
-                if (this.threadData.comments.length > 0) {
-                    this.scrollDown();
-                }
-
-                $(this.$el).find('.writer textarea').autoResize({
-                    animateDuration: 0,
-                    // More extra space:
-                    extraSpace: 1
-                });
-
-                $(this.$el).find('.comments .content .html').each(function () {
-                    STUDIP.Markup.element(this);
-                });
-
-                if (this.threadData.thread_posting.thread_id) {
-                    let memory = sessionStorage.getItem(`BlubberMemory-Writer-${this.threadData.thread_posting.thread_id}`);
-                    if (memory) {
-                        $(this.$el)
-                            .find('.writer').addClass('filled')
-                            .find('textarea').val(memory);
-                    }
-                }
-            });
-        },
-        computed: {
-            hasThreadsWidget() {
-                return document.getElementById("blubber-threads-widget");
-            },
-            sortedComments () {
-                return [...this.threadData.comments].sort((a, b) => a.mkdate - b.mkdate);
-            },
-            writerTextareaPlaceholder() {
-                return this.hasContent(this.threadData.thread_posting.content)
-                    ? this.$gettext('Kommentar schreiben. Enter zum Abschicken.')
-                    : this.$gettext('Nachricht schreiben. Enter zum Abschicken.');
-            }
-        },
-        updated () {
-            this.$nextTick(function () {
-                if (this.threadData.thread_posting.thread_id) {
-                    let memory = sessionStorage.getItem('BlubberMemory-Writer-' + this.threadData.thread_posting.thread_id);
-                    $(this.$el).find('.writer textarea').val(memory);
-                }
-            });
-        },
-        watch: {
-            thread_data(current) {
-                this.threadData = current;
-            },
-            threadData (new_data, old_data) {
-                if (new_data.thread_posting.thread_id !== old_data.thread_posting.thread_id) {
-                    //if the thread got reloaded by a new thread
-                    //markup contents
-                    this.$nextTick(function () {
-                        $(this.$el).find(".comments .content .html").each(function () {
-                            STUDIP.Markup.element(this);
-                        });
-                    });
-                    //and scroll down:
-                    this.scrollDown();
-                }
-            }
-        }
-    }
-</script>
diff --git a/resources/vue/components/BlubberThreadWidget.vue b/resources/vue/components/BlubberThreadWidget.vue
deleted file mode 100644
index c62bf3c143b..00000000000
--- a/resources/vue/components/BlubberThreadWidget.vue
+++ /dev/null
@@ -1,116 +0,0 @@
-<template>
-    <div class="scrollable_area blubber_thread_widget" v-scroll>
-        <transition-group name="blubberthreadwidget-list"
-                          tag="ol">
-                <li v-for="thread in sortedThreads"
-                    :key="thread.thread_id"
-                    :data-thread_id="thread.thread_id"
-                    :class="(active_thread === thread.thread_id ? 'active' : '') + (thread.unseen_comments > 0 ? ' unseen' : '')"
-                    :data-unseen_comments="thread.unseen_comments"
-                    @click.prevent="changeActiveThread">
-                    <a :href="link(thread.thread_id)">
-                        <div class="avatar"
-                             :style="{ backgroundImage: 'url(' + thread.avatar + ')' }">
-                        </div>
-                        <div class="info">
-                            <div class="name">
-                                {{ thread.name }}
-                            </div>
-                            <studip-date-time :timestamp="thread.timestamp" :relative="true"></studip-date-time>
-                        </div>
-                    </a>
-                </li>
-                <li class="more" v-if="display_more_down" key="more">
-                    <studip-asset-img file="loading-indicator.svg" width="20"></studip-asset-img>
-                </li>
-        </transition-group>
-    </div>
-</template>
-
-<script>
-    export default {
-        name: 'blubber-thread-widget',
-        props: ['threads', 'active_thread', 'more_down'],
-        data () {
-            return {
-                display_more_down: this.more_down,
-                already_loading_down: 0,
-                allThreads: this.threads
-            };
-        },
-        methods: {
-            changeActiveThread (event) {
-                let li = $(event.target).closest('li');
-                if (!li.hasClass('active')) {
-                    li.siblings('.active').removeClass('active');
-                    li.addClass('active');
-                    this.$root.changeActiveThread(li.data('thread_id'));
-                }
-            },
-            link (thread_id) {
-                return STUDIP.URLHelper.getURL(`dispatch.php/blubber/index/${thread_id}`);
-            },
-            addThread (thread) {
-                let thread_ids = this.allThreads.map((t) => t.thread_id);
-                if (thread_ids.indexOf(thread.thread_id) !== -1) {
-                    return;
-                }
-                this.allThreads.push(thread);
-            }
-        },
-        directives: {
-            scroll: {
-                // directive definition
-                inserted (el) {
-                    let threads = el.__vue__;
-                    $(el).parent().parent().on('scroll', function (event) {
-                        let top = $(el).parent().parent().scrollTop();
-                        let height = $(el).height();
-
-                        $(el).toggleClass('scrolled', top > 0);
-
-                        if (!threads.display_more_down || (top <= height - 1000) || threads.already_loading_down) {
-                            return;
-                        }
-
-                        threads.already_loading_down = true;
-
-                        let latest_timestamp = threads.threads.reduce((max, thread) => {
-                            if (thread.thread_id === 'global') {
-                                return max;
-                            }
-                            return max === null ? thread.timestamp : Math.min(max, thread.timestamp);
-                        }, null);
-
-                        //load newer comments
-                        STUDIP.api.GET('blubber/threads', {
-                            data: {
-                                modifier: 'olderthan',
-                                timestamp: latest_timestamp,
-                                limit: 50
-                            }
-                        }).done((data) => {
-                            data.threads.forEach((thread) => threads.addThread(thread));
-
-                            threads.display_more_down = data.more_down;
-                        }).always(() => {
-                            threads.already_loading_down = false;
-                        });
-                    });
-                }
-            }
-        },
-        mounted: function () {
-
-        },
-        computed: {
-            sortedThreads () {
-                return [...this.allThreads].sort((a, b) => {
-                    return b.timestamp - a.timestamp
-                        || b.mkdate - a.mkdate
-                        || b.name.localeCompare(a.name);
-                });
-            }
-        }
-    }
-</script>
diff --git a/resources/vue/components/SidebarWidget.vue b/resources/vue/components/SidebarWidget.vue
index 34d935d84fa..88c283469aa 100644
--- a/resources/vue/components/SidebarWidget.vue
+++ b/resources/vue/components/SidebarWidget.vue
@@ -2,8 +2,11 @@
     <div class="sidebar-widget">
         <div class="sidebar-widget-header" v-if="title">
             {{ title }}
+            <div class="actions" v-if="this.$slots.actions">
+                <slot name="actions"></slot>
+            </div>
         </div>
-        <div class="sidebar-widget-content">
+        <div class="sidebar-widget-content" ref="scrollable">
             <slot name="content" />
         </div>
     </div>
@@ -15,5 +18,23 @@ export default {
     props: {
         title: String,
     },
-}
+    methods: {
+        handleScroll(event) {
+            this.$emit('scroll', { event, element: this.$refs.scrollable });
+        },
+    },
+    mounted() {
+        this.handleDebouncedScroll = _.debounce(this.handleScroll, 100);
+        this.$refs.scrollable.addEventListener('scroll', this.handleDebouncedScroll);
+    },
+    beforeDestroy() {
+        this.$refs.scrollable.removeEventListener('scroll', this.handleDebouncedScroll);
+    },
+};
 </script>
+
+<style scoped>
+.actions {
+    float: right;
+}
+</style>
diff --git a/resources/vue/components/blubber/Comment.vue b/resources/vue/components/blubber/Comment.vue
new file mode 100644
index 00000000000..93d7432b35e
--- /dev/null
+++ b/resources/vue/components/blubber/Comment.vue
@@ -0,0 +1,118 @@
+<template>
+    <li :class="commentClass">
+        <a
+            :href="userProfileURL"
+            class="avatar"
+            :title="comment.author['formatted-name']"
+            :style="{ backgroundImage: 'url(' + commentAvatar + ')' }"
+        ></a>
+        <div class="content" :class="{ editing }">
+            <a :href="userProfileURL" class="name">{{ comment.author['formatted-name'] }}</a>
+            <div ref="html" v-html="comment['content-html']" class="html"></div>
+            <textarea
+                ref="textarea"
+                class="edit"
+                v-model="localText"
+                @keydown.enter.exact.prevent="saveComment"
+                @keyup.escape.exact="doneEditing"
+            ></textarea>
+        </div>
+        <div class="time">
+            <studip-date-time :timestamp="commentMkdate" :relative="true"></studip-date-time>
+            <a
+                href=""
+                v-if="comment['is-writable']"
+                @click.prevent.stop="editComment"
+                class="edit_comment"
+                :title="$gettext('Bearbeiten.')"
+            >
+                <studip-icon shape="edit" :size="14" role="inactive"></studip-icon>
+            </a>
+            <a href="" @click.prevent="answerComment" class="answer_comment" :title="$gettext('Hierauf antworten.')">
+                <studip-icon shape="export" :size="14" role="inactive"></studip-icon>
+            </a>
+        </div>
+    </li>
+</template>
+<script>
+export default {
+    name: 'BlubberComment',
+    data: () => ({
+        localText: '',
+    }),
+    props: {
+        comment: {
+            type: Object,
+            required: true,
+        },
+        editing: {
+            type: Boolean,
+            default: false,
+        },
+    },
+    computed: {
+        commentAvatar() {
+            return this.comment.author?.avatar.small ?? '';
+        },
+        commentClass() {
+            return this.comment.isMine() ? 'mine' : 'theirs';
+        },
+        commentMkdate() {
+            return new Date(this.comment.mkdate) / 1000;
+        },
+        userProfileURL() {
+            const user_id = this.comment.author.id;
+            const username = this.comment.author.username;
+            if (username) {
+                return window.STUDIP.URLHelper.getURL('dispatch.php/profile', { username });
+            } else {
+                return window.STUDIP.URLHelper.getURL('dispatch.php/profile/extern/' + user_id);
+            }
+        },
+    },
+    methods: {
+        answerComment() {
+            this.$emit('answer-comment', this.comment);
+        },
+        editComment() {
+            this.$emit('edit-comment', this.comment);
+            this.resetContent();
+            this.focusContent();
+        },
+        doneEditing() {
+            this.resetContent();
+            this.$emit('edit-comment', null);
+        },
+        focusContent() {
+            this.$nextTick(() => {
+                const textarea = this.$refs.textarea;
+                textarea.focus();
+                textarea.setSelectionRange(textarea.value.length, textarea.value.length);
+            });
+        },
+        resetContent() {
+            this.localText = this.comment.content;
+        },
+        saveComment() {
+            if (this.localText.trim().length > 0) {
+                this.$emit('change-comment', { ...this.comment, content: this.localText });
+            } else {
+                this.$emit('remove-comment', this.comment);
+            }
+        },
+    },
+    mounted() {
+        this.resetContent();
+        this.$nextTick(() => {
+            window.STUDIP.Markup.element(this.$refs.html);
+        });
+    },
+    watch: {
+        editing(newValue, oldValue) {
+            if (!oldValue && newValue) {
+                this.focusContent();
+            }
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/blubber/CommunityPage.vue b/resources/vue/components/blubber/CommunityPage.vue
new file mode 100644
index 00000000000..8831978d45b
--- /dev/null
+++ b/resources/vue/components/blubber/CommunityPage.vue
@@ -0,0 +1,89 @@
+<template>
+    <div>
+        <BlubberPanel :threadId="threadId" :search="search" v-if="threadId" />
+
+        <MountingPortal mountTo="#blubber-search-widget" name="sidebar-blubber-search">
+            <BlubberSearchWidget :search="search" />
+        </MountingPortal>
+        <MountingPortal mountTo="#blubber-threads-widget" name="sidebar-blubber-threads">
+            <BlubberThreadsWidget
+                :hasMoreThreads="hasMoreThreads"
+                :threadId="threadId"
+                :threads="threads"
+                @load-more-threads="onLoadMoreThreads"
+                @select-thread="onSelectThread"
+                class="blubber_threads_widget"
+            />
+        </MountingPortal>
+    </div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import BlubberPanel from './Panel.vue';
+import BlubberSearchWidget from './SearchWidget.vue';
+import BlubberThreadsWidget from './ThreadsWidget.vue';
+
+export default {
+    props: {
+        initialThreadId: {
+            type: String,
+            required: true,
+        },
+        search: {
+            type: String,
+            default: '',
+        },
+    },
+    components: {
+        BlubberPanel,
+        BlubberSearchWidget,
+        BlubberThreadsWidget,
+    },
+    data: () => ({
+        handleSelectBlubberThread: null,
+        threadId: null,
+    }),
+    computed: {
+        ...mapGetters({
+            hasMoreThreads: 'studip/blubber/hasMoreThreads',
+            threads: 'studip/blubber/threads',
+        }),
+    },
+    methods: {
+        ...mapActions({
+            fetchThreads: 'studip/blubber/fetchThreads',
+        }),
+        onLoadMoreThreads() {
+            this.fetchThreads({ search: this.search, more: true });
+        },
+        onSelectThread(threadId, changeHistory = true) {
+            if (changeHistory) {
+                const url = window.STUDIP.URLHelper.getURL(`dispatch.php/blubber/index/${threadId}`);
+                window.history.pushState({ threadId }, '', url);
+            }
+            this.threadId = threadId;
+        },
+    },
+    async beforeMount() {
+        await this.fetchThreads({ search: this.search });
+        this.onSelectThread(this.initialThreadId, false);
+
+        this.handleSelectBlubberThread = (threadId) => {
+            this.onSelectThread(threadId);
+            this.fetchThreads({ search: this.search });
+        };
+        this.globalOn('studip:select-blubber-thread', this.handleSelectBlubberThread);
+    },
+    created() {
+        window.addEventListener('popstate', (event) => {
+            if ('threadId' in event.state) {
+                this.onSelectThread(event.state.threadId, false);
+            }
+        });
+    },
+    beforeDestroy() {
+        this.globalOff('studip:select-blubber-thread', this.handleSelectBlubberThread);
+    },
+};
+</script>
diff --git a/resources/vue/components/blubber/Composer.vue b/resources/vue/components/blubber/Composer.vue
new file mode 100644
index 00000000000..f4b6aff8de4
--- /dev/null
+++ b/resources/vue/components/blubber/Composer.vue
@@ -0,0 +1,126 @@
+<template>
+    <div class="writer" :style="composerStyle">
+        <studip-icon shape="blubber" :size="30" role="info"></studip-icon>
+        <textarea
+            :placeholder="placeholder || $gettext('Schreib was, frag was. Enter zum Abschicken.')"
+            v-model="localText"
+            @change="saveCommentToSession"
+            @focus="resizeTextarea"
+            @keydown.enter.exact="submit"
+            @keyup.up.exact="editPreviousComment"
+            @keyup="saveCommentToSession"
+            ref="textarea"
+        ></textarea>
+        <a class="send" @click="submit" :title="$gettext('Abschicken')">
+            <studip-icon shape="arr_2up" :size="30"></studip-icon>
+        </a>
+        <label class="upload" :title="$gettext('Datei hochladen')" tabindex="0" ref="label" @keydown="simulateClick">
+            <input type="file" multiple style="display: none" @change="onFilesPick" />
+            <studip-icon shape="upload" :size="30"></studip-icon>
+        </label>
+    </div>
+</template>
+<script>
+export default {
+    name: 'blubber-composer',
+    model: {
+        prop: 'text',
+        event: 'change',
+    },
+    props: {
+        placeholder: {
+            type: String,
+            default: '',
+        },
+        progress: {
+            type: Number,
+            default: 0,
+        },
+        text: {
+            type: String,
+            default: '',
+        },
+    },
+    data: () => ({
+        localText: '',
+    }),
+    computed: {
+        composerStyle() {
+            return {
+                'background-size': `${this.progress}%`,
+            };
+        },
+    },
+    methods: {
+        editPreviousComment() {
+            this.$emit('edit-previous');
+        },
+        focusTextarea() {
+            this.$refs.textarea.focus();
+            this.$refs.textarea.setSelectionRange(0, 0);
+        },
+        onFilesPick(event) {
+            let files =
+                event.dataTransfer !== undefined
+                    ? event.dataTransfer.files // file drop
+                    : event.target.files; // upload button
+            this.$emit('pick-files', files);
+        },
+        reset() {
+            this.localText = '';
+        },
+        resizeTextarea() {
+            const { textarea } = this.$refs;
+
+            const style = window.getComputedStyle(textarea, null);
+            let heightOffset;
+
+            if (style.boxSizing === 'content-box') {
+                heightOffset = -(parseFloat(style.paddingTop) + parseFloat(style.paddingBottom));
+            } else {
+                heightOffset = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
+            }
+            if (isNaN(heightOffset)) {
+                heightOffset = 0;
+            }
+
+            textarea.style.height = '';
+            textarea.style.height = (textarea.scrollHeight + heightOffset) + 'px';
+        },
+        simulateClick(event) {
+            if (event.code === 'Enter') {
+                this.$refs.label.click();
+            }
+        },
+        submit(event) {
+            const text = this.localText;
+            this.reset();
+
+            if (text.trim().length === 0) {
+                return false;
+            }
+
+            event.preventDefault();
+            this.$emit('add-posting', text);
+        },
+        saveCommentToSession() {
+            this.resizeTextarea();
+            this.$emit('change', this.localText);
+        },
+    },
+    mounted() {
+        this.localText = this.text;
+        this.$nextTick(() => {
+            this.resizeTextarea();
+        });
+    },
+    watch: {
+        text(newText, oldText) {
+            if (this.localText !== newText) {
+                this.localText = newText;
+                this.focusTextarea();
+            }
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/blubber/DialogPanel.vue b/resources/vue/components/blubber/DialogPanel.vue
new file mode 100644
index 00000000000..3afcddfc3f7
--- /dev/null
+++ b/resources/vue/components/blubber/DialogPanel.vue
@@ -0,0 +1,36 @@
+<template>
+    <div>
+        <BlubberPanel :threadId="threadId" :search="search" v-if="threadId" />
+    </div>
+</template>
+
+<script>
+import BlubberPanel from './Panel.vue';
+
+export default {
+    props: {
+        initialThreadId: {
+            type: String,
+            required: true,
+        },
+        search: {
+            type: String,
+            default: '',
+        },
+    },
+    components: {
+        BlubberPanel,
+    },
+    data: () => ({
+        threadId: null,
+    }),
+    methods: {
+        onSelectThread(threadId) {
+            this.threadId = threadId;
+        },
+    },
+    beforeMount() {
+        this.onSelectThread(this.initialThreadId, false);
+    },
+};
+</script>
diff --git a/resources/vue/components/blubber/Panel.vue b/resources/vue/components/blubber/Panel.vue
new file mode 100644
index 00000000000..12f15a263b6
--- /dev/null
+++ b/resources/vue/components/blubber/Panel.vue
@@ -0,0 +1,172 @@
+<template>
+    <StudipProgressIndicator
+        class="cw-loading-indicator-content"
+        :description="$gettext('Lade Kommentare...')"
+        v-if="!doneFetching"
+    />
+
+    <div class="blubber_panel" v-else-if="thread">
+        <div id="blubber_stream_container">
+            <BlubberThread
+                ref="thread"
+                :comments="comments"
+                :thread="thread"
+                :moreCommentsDown="moreCommentsDown(thread.id)"
+                :moreCommentsUp="moreCommentsUp(thread.id)"
+                :uploadProgress="uploadProgress"
+                @load-older="onLoadOlder"
+                @load-newer="onLoadNewer"
+                @add-posting="onAddPosting"
+                @change-comment="onChangeComment"
+                @pick-files="onPickFiles"
+                @remove-comment="onRemoveComment"
+                @subscribe-thread="onSubscribeThread"
+            ></BlubberThread>
+        </div>
+        <BlubberSideInfo :thread="thread" />
+    </div>
+</template>
+
+<script>
+import axios from 'axios';
+import { mapActions, mapGetters } from 'vuex';
+import BlubberSideInfo from './SideInfo.vue';
+import BlubberThread from './Thread.vue';
+import StudipProgressIndicator from '../StudipProgressIndicator.vue';
+
+export default {
+    props: {
+        search: {
+            type: String,
+            default: '',
+        },
+        threadId: {
+            type: String,
+            required: true,
+        },
+    },
+    components: {
+        BlubberSideInfo,
+        BlubberThread,
+        StudipProgressIndicator,
+    },
+    data: () => ({
+        doneFetching: false,
+        selectHandler: null,
+        uploadProgress: 0,
+    }),
+    computed: {
+        ...mapGetters({
+            getComments: 'studip/blubber/comments',
+            moreCommentsDown: 'studip/blubber/moreNewer',
+            moreCommentsUp: 'studip/blubber/moreOlder',
+            getThread: 'studip/blubber/thread',
+        }),
+        comments() {
+            return this.threadId ? this.getComments(this.threadId) : [];
+        },
+        thread() {
+            return this.threadId ? this.getThread(this.threadId) : null;
+        },
+    },
+    methods: {
+        ...mapActions({
+            changeThreadSubscription: 'studip/blubber/changeThreadSubscription',
+            createComment: 'studip/blubber/createComment',
+            destroyComment: 'studip/blubber/destroyComment',
+            fetchThread: 'studip/blubber/fetchThread',
+            loadNewerComments: 'studip/blubber/loadNewerComments',
+            loadOlderComments: 'studip/blubber/loadOlderComments',
+            markThreadAsSeen: 'studip/blubber/markThreadAsSeen',
+            setThreadAsDefault: 'studip/blubber/setThreadAsDefault',
+            updateComment: 'studip/blubber/updateComment',
+        }),
+
+        onAddPosting(content) {
+            this.createComment({ id: this.threadId, content })
+                .then(() => {
+                    this.$refs.thread.scrollDown();
+                })
+                .catch((error) => {
+                    STUDIP.Report.error(
+                        this.$gettext('Fehler beim Erstellen Ihres Kommentars'),
+                        [
+                            this.$gettext(
+                                'Ein technisches Problem verhindert, dass Ihr Kommentar erstellt werden konnte.'
+                            ),
+                        ].join(' ')
+                    );
+                    console.error('Could not create comment', error);
+                });
+        },
+        onChangeComment(comment) {
+            this.updateComment(comment);
+        },
+        onLoadNewer() {
+            this.loadNewerComments({ id: this.threadId, search: this.search });
+        },
+        onLoadOlder() {
+            this.loadOlderComments({ id: this.threadId, search: this.search });
+        },
+        onPickFiles(files) {
+            const data = new FormData();
+            for (let i in files) {
+                if (files[i].size > 0) {
+                    data.append(`file_${i}`, files[i], files[i].name.normalize());
+                }
+            }
+
+            axios({
+                method: 'POST',
+                url: STUDIP.URLHelper.getURL('dispatch.php/blubber/upload_files'),
+                data,
+                onUploadProgress: ({ loaded, position, lengthComputable, total }) => {
+                    if (lengthComputable) {
+                        this.uploadProgress = Math.ceil(((loaded || position) / total) * 100);
+                    }
+                },
+            })
+                .then(({ data }) => {
+                    this.onAddPosting(data.inserts.join(' '));
+                })
+                .catch((error) => {
+                    STUDIP.Report(
+                        this.$gettext('Fehler beim Hochladen'),
+                        [
+                            this.$gettext(
+                                'Ein technisches Problem verhindert, dass Ihre Datei hochgeladen werden konnte.'
+                            ),
+                        ].join(' ')
+                    );
+                    console.error('Could not upload files', error);
+                })
+                .finally(() => {
+                    this.uploadProgress = 0;
+                });
+        },
+        onRemoveComment(comment) {
+            this.destroyComment(comment);
+        },
+        onSubscribeThread(subscribeThread) {
+            this.changeThreadSubscription({ id: this.threadId, subscribe: subscribeThread });
+        },
+        selectThread(threadId) {
+            this.doneFetching = false;
+            this.fetchThread({ id: threadId, search: this.search }).then(() => {
+                this.doneFetching = true;
+                this.markThreadAsSeen({ id: threadId });
+                this.thread.unseenComments = 0;
+                this.setThreadAsDefault({ id: threadId });
+            });
+        },
+    },
+    mounted() {
+        this.selectThread(this.threadId);
+    },
+    watch: {
+        threadId(newId) {
+            this.selectThread(newId);
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/blubber/SearchWidget.vue b/resources/vue/components/blubber/SearchWidget.vue
new file mode 100644
index 00000000000..0c90f3c9c5b
--- /dev/null
+++ b/resources/vue/components/blubber/SearchWidget.vue
@@ -0,0 +1,62 @@
+<template>
+    <SidebarWidget :title="$gettext('Suche')">
+        <template #content>
+            <form action="?#" method="get" class="sidebar-search">
+                <ul class="needles">
+                    <li>
+                        <div class="input-group files-search">
+                            <label :for="inputId" class="sr-only">{{ $gettext('Suche nach …') }}</label>
+                            <input
+                                type="text"
+                                :id="inputId"
+                                name="search"
+                                :value="search"
+                                :placeholder="$gettext('Suche nach …')"
+                            />
+
+                            <a
+                                class="reset-search"
+                                :href="urlReset"
+                                tabindex="0"
+                                role="button"
+                                :title="$gettext('Suche zurücksetzen')"
+                            >
+                                <studip-icon shape="decline" :size="20" alt="" />
+                            </a>
+
+                            <button type="submit" class="submit-search" :title="$gettext('Suche ausführen')">
+                                <studip-icon shape="search" :size="20" alt="" />
+                            </button>
+                        </div>
+                    </li>
+                </ul>
+            </form>
+        </template>
+    </SidebarWidget>
+</template>
+
+<script>
+import SidebarWidget from '../SidebarWidget.vue';
+
+let nextId = 0;
+
+export default {
+    props: {
+        search: {
+            type: String,
+            default: '',
+        },
+    },
+    components: {
+        SidebarWidget,
+    },
+    data: () => ({
+        inputId: ++nextId,
+    }),
+    computed: {
+        urlReset() {
+            return STUDIP.URLHelper.getURL('dispatch.php/blubber');
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/blubber/SideInfo.vue b/resources/vue/components/blubber/SideInfo.vue
new file mode 100644
index 00000000000..77aa9418a42
--- /dev/null
+++ b/resources/vue/components/blubber/SideInfo.vue
@@ -0,0 +1,16 @@
+<template>
+    <div class="blubber_sideinfo responsive-hidden" v-if="thread['context-info']">
+        <div class="context_info" v-html="thread['context-info']"></div>
+    </div>
+</template>
+
+<script>
+export default {
+    props: {
+        thread: {
+            type: Object,
+            required: true,
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/blubber/Thread.vue b/resources/vue/components/blubber/Thread.vue
new file mode 100644
index 00000000000..2a530319d60
--- /dev/null
+++ b/resources/vue/components/blubber/Thread.vue
@@ -0,0 +1,252 @@
+<template>
+    <div
+        class="blubber_thread"
+        :class="{ dragover: dragging }"
+        :id="blubberThreadId"
+        @dragover.prevent="dragover"
+        @dragleave.prevent="dragleave"
+        @drop.prevent="onDrop"
+    >
+        <ThreadSubscriber
+            v-if="threadNotifications"
+            class="hidden-medium-up"
+            :followed="threadFollowed"
+            @subscribe-thread="onSubscribeThread"
+        />
+        <div class="scrollable_area" :class="{ scrolled }" ref="scrollable">
+            <div class="all_content">
+                <div v-if="emptyBlubber" class="empty_blubber_background">
+                    <div>{{ $gettext('Starte die Konversation jetzt!') }}</div>
+                </div>
+
+                <ol class="comments" aria-live="polite">
+                    <li class="more" v-if="moreCommentsUp">
+                        <studip-asset-img file="loading-indicator.svg" width="20"></studip-asset-img>
+                    </li>
+
+                    <BlubberComment
+                        v-for="comment in sortedComments"
+                        :key="comment.id"
+                        :comment="comment"
+                        :editing="commentEditing && comment.id === commentEditing.id"
+                        @answer-comment="onAnswerComment"
+                        @change-comment="onChangeComment"
+                        @edit-comment="onEditComment"
+                        @remove-comment="onRemoveComment"
+                    ></BlubberComment>
+
+                    <li class="more" v-if="moreCommentsDown">
+                        <studip-asset-img file="loading-indicator.svg" width="20"></studip-asset-img>
+                    </li>
+                </ol>
+            </div>
+        </div>
+
+        <BlubberComposer
+            ref="composer"
+            v-if="threadCommentable"
+            v-model="composerText"
+            @change="onChangeComposerText"
+            :placeholder="writerTextareaPlaceholder"
+            :progress="uploadProgress"
+            @add-posting="onAddPosting"
+            @edit-previous="onEditPrevious"
+            @pick-files="onPickFiles"
+        ></BlubberComposer>
+    </div>
+</template>
+
+<script>
+import BlubberComment from './Comment.vue';
+import BlubberComposer from './Composer.vue';
+import ThreadSubscriber from './ThreadSubscriber.vue';
+
+export default {
+    name: 'blubber-thread',
+    components: {
+        BlubberComment,
+        BlubberComposer,
+        ThreadSubscriber,
+    },
+    props: {
+        comments: {
+            type: Array,
+            required: true,
+        },
+        thread: {
+            type: Object,
+            required: true,
+        },
+        moreCommentsDown: {
+            type: Boolean,
+            default: false,
+        },
+        moreCommentsUp: {
+            type: Boolean,
+            default: false,
+        },
+        uploadProgress: {
+            type: Number,
+            default: 0,
+        },
+    },
+    data: () => ({
+        commentEditing: null,
+        composerText: '',
+        dragging: false,
+        scrolled: false,
+        scrollPosition: {},
+    }),
+    computed: {
+        threadCommentable() {
+            return this.thread['is-commentable'];
+        },
+        threadFollowed() {
+            return this.thread['is-followed'];
+        },
+        threadNotifications() {
+            return this.thread['may-disable-notifications'];
+        },
+
+        blubberThreadId() {
+            return 'blubberthread_' + this.thread.id;
+        },
+        emptyBlubber() {
+            return this.comments.length === 0;
+        },
+        sortedComments() {
+            return _.sortBy(this.comments, 'mkdate');
+        },
+        writerTextareaPlaceholder() {
+            return this.$gettext('Nachricht schreiben. Enter zum Abschicken.');
+        },
+    },
+    methods: {
+        dragover(event) {
+            this.dragging = event.dataTransfer.types.includes('Files');
+        },
+        dragleave(event) {
+            this.dragging = false;
+        },
+
+        scrollDown() {
+            this.$nextTick(() => {
+                const scrollable = this.$refs.scrollable;
+                const scroll = () => {
+                    const height = this.$refs.scrollable.querySelector('.all_content').getBoundingClientRect().height;
+                    scrollable.scrollTo(0, height);
+                };
+                scrollable.querySelectorAll('img').forEach((img) => img.addEventListener('load', scroll));
+                scroll();
+            });
+        },
+
+        handleScroll(event) {
+            const el = this.$refs.scrollable;
+            const threadPosting = el.querySelector('.all_content');
+            const threadPostingHeight = threadPosting?.scrollHeight ?? 0;
+
+            this.scrolled = el.scrollTop > 0;
+
+            if (this.threadMoreUp && el.scrollTop < 1000) {
+                this.$emit('load-older');
+            }
+
+            if (this.threadMoreDown && el.scrollTop > threadPostingHeight - 1000) {
+                this.$emit('load-newer');
+            }
+        },
+
+        onAddPosting(text) {
+            this.$emit('add-posting', text);
+            clearBlubberMemory(this.thread);
+        },
+        onAnswerComment(comment) {
+            const quoteContent = comment.content.replace(/\[quote[^\]]*\].*\[\/quote\]/g, '').trim();
+            const quote = `[quote=${comment.author['formatted-name']}]${quoteContent}[/quote]\n`;
+            this.composerText = quote;
+        },
+        onChangeComment(comment) {
+            this.commentEditing = null;
+            this.$emit('change-comment', comment);
+            this.$refs.composer.focusTextarea();
+        },
+        onChangeComposerText(text) {
+            setBlubberMemory(this.thread, text);
+        },
+        onDrop(event) {
+            if (!event.dataTransfer?.types.includes('Files')) {
+                return;
+            }
+
+            this.$emit('pick-files', event.dataTransfer.files);
+            this.dragleave();
+        },
+        onEditComment(comment) {
+            this.commentEditing = comment;
+        },
+        onEditPrevious() {
+            this.commentEditing = this.sortedComments[
+                this.sortedComments.findLastIndex((comment) => {
+                    return comment.isMine();
+                })
+            ];
+        },
+        onPickFiles(files) {
+            this.$emit('pick-files', files);
+        },
+        onRemoveComment(comment) {
+            this.commentEditing = null;
+            this.$emit('remove-comment', comment);
+        },
+        onSubscribeThread(subscribeThread) {
+            this.$emit('subscribe-thread', subscribeThread);
+        },
+    },
+    mounted() {
+        this.handleDebouncedScroll = _.debounce(this.handleScroll, 100);
+        this.$refs.scrollable.addEventListener('scroll', this.handleDebouncedScroll);
+
+        // when everything is initialized
+        this.$nextTick(() => {
+            if (this.comments.length > 0) {
+                this.scrollDown();
+            }
+
+            const memory = getBlubberMemory(this.thread);
+            if (memory) {
+                this.composerText = memory;
+            }
+        });
+    },
+    beforeDestroy() {
+        this.$refs.scrollable.removeEventListener('scroll', this.handleDebouncedScroll);
+    },
+    beforeUpdate() {
+        const { scrollHeight, scrollTop } = this.$refs.scrollable;
+        this.scrollPosition = { scrollHeight, scrollTop };
+    },
+    updated() {
+        // maintain scroll position when loading older comments
+        const newScrollTop =
+            this.$refs.scrollable.scrollHeight - this.scrollPosition.scrollHeight + this.scrollPosition.scrollTop;
+        this.$refs.scrollable.scrollTo(0, newScrollTop);
+    },
+};
+
+function clearBlubberMemory(thread) {
+    if (thread?.id) {
+        window.sessionStorage.removeItem(`BlubberMemory-Writer-${thread.id}`);
+    }
+}
+
+function getBlubberMemory(thread) {
+    return thread?.id ? window.sessionStorage.getItem(`BlubberMemory-Writer-${thread.id}`) : null;
+}
+
+function setBlubberMemory(thread, memory) {
+    if (thread?.id) {
+        window.sessionStorage.setItem(`BlubberMemory-Writer-${thread.id}`, memory);
+    }
+}
+</script>
diff --git a/resources/vue/components/blubber/ThreadSubscriber.vue b/resources/vue/components/blubber/ThreadSubscriber.vue
new file mode 100644
index 00000000000..1bfb8bd0f6f
--- /dev/null
+++ b/resources/vue/components/blubber/ThreadSubscriber.vue
@@ -0,0 +1,32 @@
+<template>
+    <div class="context_info">
+        <a
+            href="#"
+            @click.prevent="onClick"
+            class="followunfollow"
+            :class="{ unfollowed: !followed }"
+            :title="$gettext('Benachrichtigungen für diese Konversation abstellen.')"
+        >
+            <StudipIcon v-if="!followed" shape="decline" :size="20" class="text-bottom"></StudipIcon>
+            <StudipIcon v-else shape="notification2" :size="20" class="text-bottom"></StudipIcon>
+            {{ $gettext('Benachrichtigungen aktiviert') }}
+        </a>
+    </div>
+</template>
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+    props: {
+        followed: {
+            type: Boolean,
+            required: true,
+        },
+    },
+    methods: {
+        onClick() {
+            this.$emit('subscribe-thread', !this.followed);
+        },
+    },
+});
+</script>
diff --git a/resources/vue/components/blubber/ThreadsWidget.vue b/resources/vue/components/blubber/ThreadsWidget.vue
new file mode 100644
index 00000000000..15f91abc68d
--- /dev/null
+++ b/resources/vue/components/blubber/ThreadsWidget.vue
@@ -0,0 +1,109 @@
+<template>
+    <SidebarWidget :title="$gettext('Konversationen')" @scroll="handleScroll">
+        <template #content>
+            <div class="scrollable_area blubber_thread_widget" :class="{ scrolled }" ref="scrollableArea">
+                <transition-group name="blubberthreadwidget-list" tag="ol">
+                    <li
+                        v-for="thread in sortedThreads"
+                        :key="thread.id"
+                        :data-thread_id="thread.id"
+                        :class="threadClasses(thread)"
+                        :data-unseen_comments="thread.unseenComments"
+                        @click.prevent="changeActiveThread(thread.id)"
+                    >
+                        <a :href="link(thread.id)">
+                            <div class="avatar" :style="{ backgroundImage: 'url(' + thread.avatar + ')' }"></div>
+                            <div class="info">
+                                <div class="name">
+                                    {{ thread.name }}
+                                </div>
+                                <studip-date-time
+                                    :timestamp="threadLatestActivity(thread)"
+                                    :relative="true"
+                                ></studip-date-time>
+                            </div>
+                        </a>
+                    </li>
+                    <li class="more" v-if="hasMoreThreads" key="more" ref="more">
+                        <studip-asset-img file="loading-indicator.svg" width="20"></studip-asset-img>
+                    </li>
+                </transition-group>
+            </div>
+        </template>
+
+        <template #actions>
+            <a :href="urlCompose" data-dialog="width=600;height=300">
+                <studip-icon shape="add" class="text-bottom" />
+            </a>
+        </template>
+    </SidebarWidget>
+</template>
+<script>
+import SidebarWidget from '../SidebarWidget.vue';
+
+export default {
+    props: {
+        hasMoreThreads: {
+            type: Boolean,
+            default: false,
+        },
+        threadId: {
+            type: String,
+            default: null,
+        },
+        threads: {
+            type: Array,
+            default: () => [],
+        },
+    },
+    data: () => ({
+        scrolled: false,
+    }),
+    components: {
+        SidebarWidget,
+    },
+    computed: {
+        sortedThreads() {
+            const sorted = [...this.threads].sort((a, b) => {
+                return (
+                    new Date(b['latest-activity']) - new Date(a['latest-activity'])
+                    || new Date(b['mkdate']) - new Date(a['mkdate'])
+                    || b.name.localeCompare(a.name)
+                );
+            });
+
+            return sorted;
+        },
+        urlCompose() {
+            return STUDIP.URLHelper.getURL('dispatch.php/blubber/compose');
+        },
+    },
+    methods: {
+        changeActiveThread(threadId) {
+            this.$emit('select-thread', threadId);
+        },
+        handleScroll({ element }) {
+            this.scrolled = element.scrollTop > 0;
+
+            if (
+                this.hasMoreThreads
+                && element.scrollTop >= element.scrollHeight - this.$refs.more.clientHeight - element.clientHeight
+            ) {
+                this.$emit('load-more-threads');
+            }
+        },
+        link(thread_id) {
+            return STUDIP.URLHelper.getURL(`dispatch.php/blubber/index/${thread_id}`);
+        },
+        threadClasses(thread) {
+            return {
+                active: thread.id === this.threadId,
+                unseen: thread.unseenComments > 0,
+            };
+        },
+        threadLatestActivity(thread) {
+            return new Date(thread['latest-activity']) / 1000;
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/blubber/components.js b/resources/vue/components/blubber/components.js
new file mode 100644
index 00000000000..a4040c240f5
--- /dev/null
+++ b/resources/vue/components/blubber/components.js
@@ -0,0 +1,10 @@
+export { default as BlubberComment } from './Comment.vue';
+export { default as BlubberCommunityPage } from './CommunityPage.vue';
+export { default as BlubberComposer } from './Composer.vue';
+export { default as BlubberDialogPanel } from './DialogPanel.vue';
+export { default as BlubberPanel } from './Panel.vue';
+export { default as BlubberSearchWidget } from './SearchWidget.vue';
+export { default as BlubberSideInfo } from './SideInfo.vue';
+export { default as BlubberThreadSubscriber } from './ThreadSubscriber.vue';
+export { default as BlubberThreadsWidget } from './ThreadsWidget.vue';
+export { default as BlubberThread } from './Thread.vue';
diff --git a/resources/vue/plugins/blubber.js b/resources/vue/plugins/blubber.js
new file mode 100644
index 00000000000..5e732aa5130
--- /dev/null
+++ b/resources/vue/plugins/blubber.js
@@ -0,0 +1,63 @@
+import axios from 'axios';
+import { mapResourceModules } from '@elan-ev/reststate-vuex';
+import JSUpdater from '@/assets/javascripts/lib/jsupdater.js';
+import blubberModule from '../store/blubber.js';
+import * as components from '../components/blubber/components.js';
+
+const JSONAPI_PATH = 'jsonapi.php/v1';
+
+export const BlubberPlugin = {
+    install(Vue, options = {}) {
+        if (!('store' in options)) {
+            throw new Error('You must provide the vuex store via the options argument');
+        }
+
+        this.enhanceStore(options.store);
+        this.registerComponents(Vue);
+        this.registerUpdater(options.store);
+    },
+    enhanceStore(store) {
+        const httpClient = getHttpClient(window.STUDIP.URLHelper.getURL(JSONAPI_PATH, {}, true));
+        initializeStore(store, httpClient);
+    },
+    registerComponents(Vue) {
+        Object.entries(components).forEach(([name, component]) => {
+            const exists = Vue.component(name);
+            if (!exists) {
+                Vue.component(name, component);
+            }
+        });
+    },
+    registerUpdater(store) {
+        registerUpdater(JSUpdater, store);
+    },
+};
+
+function getHttpClient(baseURL) {
+    return axios.create({ baseURL, headers: { 'Content-Type': 'application/vnd.api+json' } });
+}
+
+function initializeStore(store, httpClient) {
+    const modules = mapResourceModules({ names: ['blubber-threads', 'blubber-comments', 'users'], httpClient });
+    Object.entries(modules).forEach(([name, module]) => {
+        if (!store.hasModule(name)) {
+            store.registerModule(name, module);
+        }
+    });
+    if (!store.hasModule(['studip'])) {
+        store.registerModule(['studip'], { namespaced: true });
+    }
+    if (!store.hasModule(['studip', 'blubber'])) {
+        store.registerModule(['studip', 'blubber'], blubberModule);
+    }
+}
+
+function registerUpdater(updater, store) {
+    if (!updater.isRegistered('blubber')) {
+        updater.register(
+            'blubber',
+            (datagram) => store.dispatch('studip/blubber/updateState', datagram),
+            store.getters['studip/blubber/pollingParams']
+        );
+    }
+}
diff --git a/resources/vue/store/blubber.js b/resources/vue/store/blubber.js
new file mode 100644
index 00000000000..bd1c8e107eb
--- /dev/null
+++ b/resources/vue/store/blubber.js
@@ -0,0 +1,364 @@
+function BlubberComment() {}
+
+BlubberComment.prototype.isMine = function () {
+    return this.author?.id === window.STUDIP.USER_ID;
+};
+
+function transformComment(rootGetters, { type, id, attributes, relationships }) {
+    const author = relationships.author.data ? rootGetters['users/byId']({ id: relationships.author.data.id }) : null;
+
+    return Object.assign(new BlubberComment(), {
+        type,
+        id,
+        ...attributes,
+        author: author ? transformUser(rootGetters, author) : null,
+    });
+}
+
+function transformThread(rootGetters, { type, id, attributes, relationships, meta }) {
+    const author = rootGetters['users/related']({
+        parent: { id, type },
+        relationship: 'author',
+    });
+    return {
+        type,
+        id,
+        ...attributes,
+        author: author ? transformUser(rootGetters, author) : null,
+        avatar: meta.avatar,
+        unseenComments: relationships.comments?.links.related.meta['unseen-comments'] ?? 0,
+    };
+}
+
+function transformUser(rootGetters, { type, id, attributes, meta }) {
+    return {
+        type,
+        id,
+        ...attributes,
+        avatar: meta.avatar,
+    };
+}
+
+export default {
+    namespaced: true,
+    state: {
+        hasMoreThreads: false,
+        loadingNewer: {},
+        loadingOlder: {},
+        loadingThreads: false,
+        moreNewer: {},
+        moreOlder: {},
+    },
+    getters: {
+        comments(state, getters, rootState, rootGetters) {
+            return (threadId) => {
+                const rawComments = rootGetters['blubber-comments/all'].filter(
+                    (comment) => comment.relationships.thread.data.id === threadId
+                );
+
+                return rawComments.map((comment) => transformComment(rootGetters, comment));
+            };
+        },
+
+        hasMoreThreads(state) {
+            return state.hasMoreThreads;
+        },
+
+        isLoadingNewer(state) {
+            return (threadId) => !!state.loadingNewer[threadId];
+        },
+        isLoadingOlder(state) {
+            return (threadId) => !!state.loadingOlder[threadId];
+        },
+
+        isLoadingThreads(state) {
+            return state.loadingThreads;
+        },
+
+        moreNewer(state) {
+            return (threadId) => !!state.moreNewer[threadId];
+        },
+        moreOlder(state) {
+            return (threadId) => !!state.moreOlder[threadId];
+        },
+        pollingParams(state, getters, rootState, rootGetters) {
+            return () => ({
+                threads: rootGetters['blubber-threads/all'].map(({ id }) => id),
+            });
+        },
+        thread(state, getters, rootState, rootGetters) {
+            return (threadId) => {
+                const rawThread = rootGetters['blubber-threads/byId']({ id: threadId });
+
+                return rawThread ? transformThread(rootGetters, rawThread) : null;
+            };
+        },
+        threads(state, getters, rootState, rootGetters) {
+            return rootGetters['blubber-threads/all'].map((thread) => transformThread(rootGetters, thread));
+        },
+    },
+    mutations: {
+        setHasMoreThreads(state, hasMoreThreads) {
+            state.hasMoreThreads = hasMoreThreads;
+        },
+        setLoadingNewer(state, { id, loading }) {
+            state.loadingNewer = { ...state.loadingNewer, [id]: loading };
+        },
+        setLoadingOlder(state, { id, loading }) {
+            state.loadingOlder = { ...state.loadingOlder, [id]: loading };
+        },
+        setLoadingThreads(state, loadingThreads) {
+            state.loadingThreads = loadingThreads;
+        },
+        setMoreNewer(state, { id, hasMore }) {
+            state.moreNewer = { ...state.moreNewer, [id]: hasMore };
+        },
+        setMoreOlder(state, { id, hasMore }) {
+            state.moreOlder = { ...state.moreOlder, [id]: hasMore };
+        },
+    },
+    actions: {
+        changeThreadSubscription({ dispatch, rootGetters }, { id, subscribe }) {
+            return STUDIP.Blubber.followunfollow(id, subscribe).done((state) => {
+                const thread = rootGetters['blubber-threads/byId']({ id });
+                thread.attributes['is-followed'] = state;
+
+                return dispatch('blubber-threads/storeRecord', thread, { root: true });
+            });
+        },
+
+        createComment({ dispatch, rootGetters }, { id, content }) {
+            const data = {
+                attributes: { content },
+                relationships: {
+                    thread: {
+                        data: {
+                            type: 'blubber-threads',
+                            id,
+                        },
+                    },
+                },
+            };
+            return dispatch('blubber-comments/create', data, { root: true });
+        },
+
+        destroyComment({ dispatch }, { id }) {
+            return dispatch('blubber-comments/delete', { id }, { root: true });
+        },
+
+        async fetchThread({ commit, dispatch, rootGetters }, { id, search }) {
+            const options = {
+                include: 'author',
+                sort: '-mkdate',
+            };
+            if (search) {
+                options['filter[search]'] = search;
+            }
+
+            await Promise.all([
+                dispatch('blubber-threads/loadById', { id }, { root: true }),
+                dispatch(
+                    'blubber-comments/loadRelated',
+                    { parent: { type: 'blubber-threads', id }, relationship: 'comments', options },
+                    { root: true }
+                ),
+            ]);
+
+            // loadCurrentUser is nice enough to know whether it still needs to load the current user
+            await dispatch('loadCurrentUser');
+
+            // if total is missing, there are more comments to fetch
+            const total = rootGetters['blubber-comments/lastMeta']?.page?.total;
+            const hasMore = !total;
+            commit('setMoreOlder', { id, hasMore });
+        },
+
+        async fetchThreads({ commit, dispatch, getters, rootGetters }, { search, more = false }) {
+            if (getters.isLoadingThreads) {
+                return;
+            }
+
+            commit('setLoadingThreads', true);
+
+            const options = {
+                'page[limit]': 20,
+            };
+            const filter = {};
+            if (search) {
+                filter['search'] = search;
+            }
+            if (more) {
+                const earliestDate = rootGetters['blubber-threads/all'].reduce((earliest, thread) => {
+                    const activityDate = new Date(thread.attributes['latest-activity']);
+                    return !earliest || activityDate < earliest ? activityDate : earliest;
+                }, null);
+                if (earliestDate) {
+                    filter['before'] = earliestDate.toISOString();
+                }
+            }
+
+            await dispatch('blubber-threads/loadWhere', { filter, options }, { root: true });
+
+            const total = rootGetters['blubber-threads/lastMeta']?.page?.total;
+            const hasMore = !total;
+            commit('setHasMoreThreads', hasMore);
+
+            commit('setLoadingThreads', false);
+        },
+
+        loadCurrentUser({ dispatch, rootGetters }) {
+            const myUserId = window.STUDIP.USER_ID;
+            if (!rootGetters['users/byId']({ id: myUserId })) {
+                return dispatch('users/loadById', { id: myUserId }, { root: true });
+            }
+        },
+
+        async loadNewerComments({ commit, dispatch, getters, rootGetters }, { id, search }) {
+            if (!getters.moreNewer(id)) {
+                return;
+            }
+
+            const latestMkdate = getters.comments(id).reduce((latest, comment) => {
+                const mkdate = new Date(comment.mkdate);
+                return (latest ?? 0) < mkdate ? mkdate : latest;
+            }, null);
+
+            if (!getters.isLoadingNewer(id)) {
+                commit('setLoadingNewer', { id, loading: true });
+
+                const options = {
+                    include: 'author,thread',
+                    sort: 'mkdate',
+                };
+                if (latestMkdate) {
+                    options['filter[since]'] = latestMkdate.toISOString();
+                }
+
+                if (search) {
+                    options['filter[search]'] = search;
+                }
+
+                await dispatch(
+                    'blubber-comments/loadRelated',
+                    {
+                        parent: { type: 'blubber-threads', id },
+                        relationship: 'comments',
+                        options,
+                    },
+                    { root: true }
+                );
+
+                // if total is missing, there are more comments to fetch
+                commit('setMoreNewer', {
+                    id,
+                    hasMore: !('total' in rootGetters['blubber-comments/lastMeta'].page),
+                });
+
+                commit('setLoadingNewer', { id, loading: false });
+            }
+        },
+
+        async loadOlderComments({ commit, dispatch, getters, rootGetters }, { id, search }) {
+            if (!getters.moreOlder(id)) {
+                return;
+            }
+            const earliestMkdate = getters.comments(id).reduce((earliest, comment) => {
+                const mkdate = new Date(comment.mkdate);
+                return !earliest || earliest > mkdate ? mkdate : earliest;
+            }, null);
+
+            if (!getters.isLoadingOlder(id)) {
+                commit('setLoadingOlder', { id, loading: true });
+
+                const options = {
+                    include: 'author,thread',
+                    sort: '-mkdate',
+                };
+                if (earliestMkdate) {
+                    options['filter[before]'] = earliestMkdate.toISOString();
+                }
+                if (search) {
+                    options['filter[search]'] = search;
+                }
+
+                await dispatch(
+                    'blubber-comments/loadRelated',
+                    {
+                        parent: { type: 'blubber-threads', id },
+                        relationship: 'comments',
+                        options,
+                    },
+                    { root: true }
+                );
+
+                // if total is missing, there are more comments to fetch
+                commit('setMoreOlder', {
+                    id,
+                    hasMore: !('total' in rootGetters['blubber-comments/lastMeta'].page),
+                });
+
+                commit('setLoadingOlder', { id, loading: false });
+            }
+        },
+
+        markThreadAsSeen({ dispatch, rootGetters }, { id }) {
+            const thread = rootGetters['blubber-threads/byId']({ id });
+            const meta = thread.relationships.comments?.links.related.meta;
+            if (meta?.['unseen-comments']) {
+                thread.attributes['visited-at'] = new Date().toISOString();
+                thread.relationships.comments.links.related.meta = { ...meta, 'unseen-comments': 0 };
+                dispatch('blubber-threads/update', thread, { root: true });
+            }
+        },
+
+        setThreadAsDefault({ dispatch, rootGetters }, { id }) {
+            const parent = rootGetters['users/byId']({ id: window.STUDIP.USER_ID });
+
+            return dispatch(
+                'blubber-threads/setRelated',
+                {
+                    parent,
+                    relationship: 'blubber-default-thread',
+                    data: { type: "blubber-threads", id },
+                },
+                { root: true }
+            );
+        },
+
+        updateComment({ dispatch }, { id, content }) {
+            const data = {
+                type: 'blubber-comments',
+                id,
+                attributes: { content },
+            };
+            return dispatch('blubber-comments/update', data, { root: true }).then(() =>
+                dispatch('blubber-comments/loadById', { id }, { root: true })
+            );
+        },
+
+        updateState({ commit, dispatch }, datagram) {
+            Object.entries(datagram).forEach(([method, data]) => {
+                if (method === 'addNewComments') {
+                    return Promise.all(
+                        Object.keys(data).map((id) => {
+                            commit('setMoreNewer', { id, hasMore: true });
+                            return dispatch('loadNewerComments', { id });
+                        })
+                    );
+                } else if (method === 'removeDeletedComments') {
+                    return Promise.all(
+                        data.map((id) => {
+                            return dispatch('blubber-comments/removeRecord', { id }, { root: true });
+                        })
+                    );
+                } else if (method === 'updateThreadWidget') {
+                    return Promise.all(
+                        data.map(({ thread_id }) =>
+                            dispatch('blubber-threads/loadById', { id: thread_id }, { root: true })
+                        )
+                    );
+                }
+            });
+        },
+    },
+};
diff --git a/templates/blubber/threads-overview.php b/templates/blubber/threads-overview.php
deleted file mode 100644
index 9f5e16728e2..00000000000
--- a/templates/blubber/threads-overview.php
+++ /dev/null
@@ -1,16 +0,0 @@
-<div class="sidebar-widget blubber_threads_widget"
-     data-threads_data="<?= htmlReady(json_encode($json)) ?>">
-    <div class="sidebar-widget-header">
-        <div class="actions">
-            <? if ($with_composer) : ?>
-                <a href="<?= URLHelper::getLink("dispatch.php/blubber/compose") ?>" data-dialog="width=600;height=300">
-                    <?= Icon::create("add", "clickable")->asImg(20, ['class' => "text-bottom"]) ?>
-                </a>
-            <? endif ?>
-        </div>
-        <?= count($json) > 1 ? _("Konversationen") : _("Konversation") ?>
-    </div>
-    <div class="sidebar-widget-content">
-        <div id="blubber-threads-widget"></div>
-    </div>
-</div>
diff --git a/tests/jsonapi/BlubberThreadsCreateTest.php b/tests/jsonapi/BlubberThreadsCreateTest.php
index 4b275d54249..17846f891a4 100644
--- a/tests/jsonapi/BlubberThreadsCreateTest.php
+++ b/tests/jsonapi/BlubberThreadsCreateTest.php
@@ -18,6 +18,12 @@ class BlubberThreadsCreateTest extends \Codeception\Test\Unit
     protected function _before()
     {
         \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh);
+
+        // Create global template factory if neccessary
+        $has_template_factory = isset($GLOBALS['template_factory']);
+        if (!$has_template_factory) {
+            $GLOBALS['template_factory'] = new Flexi_TemplateFactory($GLOBALS['STUDIP_BASE_PATH'] . '/templates');
+        }
     }
 
     protected function _after()
diff --git a/tests/jsonapi/BlubberThreadsIndexTest.php b/tests/jsonapi/BlubberThreadsIndexTest.php
index ea665ab1082..ec2929c9071 100644
--- a/tests/jsonapi/BlubberThreadsIndexTest.php
+++ b/tests/jsonapi/BlubberThreadsIndexTest.php
@@ -17,6 +17,12 @@ class BlubberThreadsIndexTest extends \Codeception\Test\Unit
     protected function _before()
     {
         \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh);
+
+        // Create global template factory if neccessary
+        $has_template_factory = isset($GLOBALS['template_factory']);
+        if (!$has_template_factory) {
+            $GLOBALS['template_factory'] = new Flexi_TemplateFactory($GLOBALS['STUDIP_BASE_PATH'] . '/templates');
+        }
     }
 
     protected function _after()
diff --git a/tests/jsonapi/BlubberThreadsShowTest.php b/tests/jsonapi/BlubberThreadsShowTest.php
index 856865de39c..910338d4c1e 100644
--- a/tests/jsonapi/BlubberThreadsShowTest.php
+++ b/tests/jsonapi/BlubberThreadsShowTest.php
@@ -14,6 +14,12 @@ class BlubberThreadsShowTest extends \Codeception\Test\Unit
     protected function _before()
     {
         \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh);
+
+        // Create global template factory if neccessary
+        $has_template_factory = isset($GLOBALS['template_factory']);
+        if (!$has_template_factory) {
+            $GLOBALS['template_factory'] = new Flexi_TemplateFactory($GLOBALS['STUDIP_BASE_PATH'] . '/templates');
+        }
     }
 
     protected function _after()
diff --git a/tests/jsonapi/_bootstrap.php b/tests/jsonapi/_bootstrap.php
index d9c5adcef74..a6177dff77c 100644
--- a/tests/jsonapi/_bootstrap.php
+++ b/tests/jsonapi/_bootstrap.php
@@ -51,6 +51,7 @@ StudipAutoloader::addAutoloadPath($GLOBALS['STUDIP_BASE_PATH'].'/lib/plugins/eng
 StudipAutoloader::addAutoloadPath($GLOBALS['STUDIP_BASE_PATH'].'/lib/calendar');
 StudipAutoloader::addAutoloadPath($GLOBALS['STUDIP_BASE_PATH'].'/lib/calendar', 'Studip\\Calendar');
 StudipAutoloader::addAutoloadPath($GLOBALS['STUDIP_BASE_PATH'].'/lib/calendar/lib');
+StudipAutoloader::addAutoloadPath($GLOBALS['STUDIP_BASE_PATH'].'/lib/exceptions');
 
 StudipAutoloader::addAutoloadPath($GLOBALS['STUDIP_BASE_PATH'].'/lib/filesystem');
 StudipAutoloader::addAutoloadPath($GLOBALS['STUDIP_BASE_PATH'].'/lib/migrations');
@@ -98,3 +99,5 @@ class DB_Seminar extends DB_Sql
 }
 
 require_once __DIR__.'/../../composer/autoload.php';
+
+session_id("test-session");
-- 
GitLab