aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/blubber.php150
-rw-r--r--app/controllers/course/messenger.php47
-rw-r--r--app/views/blubber/dialog.php13
-rw-r--r--app/views/blubber/index.php25
-rw-r--r--lib/classes/JsonApi/JsonApiIntegration/QueryChecker.php9
-rw-r--r--lib/classes/JsonApi/RouteMap.php11
-rw-r--r--lib/classes/JsonApi/Routes/Blubber/Authority.php5
-rw-r--r--lib/classes/JsonApi/Routes/Blubber/CommentsByThreadIndex.php20
-rw-r--r--lib/classes/JsonApi/Routes/Blubber/CommentsCreate.php30
-rw-r--r--lib/classes/JsonApi/Routes/Blubber/Rel/DefaultThread.php128
-rw-r--r--lib/classes/JsonApi/Routes/Blubber/SortTrait.php15
-rw-r--r--lib/classes/JsonApi/Routes/Blubber/ThreadsUpdate.php56
-rw-r--r--lib/classes/JsonApi/Schemas/BlubberComment.php4
-rw-r--r--lib/classes/JsonApi/Schemas/BlubberThread.php75
-rw-r--r--lib/classes/JsonApi/Schemas/User.php27
-rw-r--r--lib/classes/sidebar/BlubberThreadsWidget.php61
-rw-r--r--lib/models/BlubberThread.php25
-rw-r--r--resources/assets/javascripts/chunk-loader.js4
-rw-r--r--resources/assets/javascripts/entry-base.js1
-rw-r--r--resources/assets/javascripts/init.js3
-rw-r--r--resources/assets/javascripts/lib/blubber.js266
-rw-r--r--resources/assets/javascripts/lib/jsupdater.js6
-rw-r--r--resources/assets/stylesheets/scss/blubber.scss2
-rw-r--r--resources/vue/components/BlubberGlobalstream.vue130
-rw-r--r--resources/vue/components/BlubberPublicComposer.vue98
-rw-r--r--resources/vue/components/BlubberThread.vue526
-rw-r--r--resources/vue/components/BlubberThreadWidget.vue116
-rw-r--r--resources/vue/components/SidebarWidget.vue25
-rw-r--r--resources/vue/components/blubber/Comment.vue118
-rw-r--r--resources/vue/components/blubber/CommunityPage.vue89
-rw-r--r--resources/vue/components/blubber/Composer.vue126
-rw-r--r--resources/vue/components/blubber/DialogPanel.vue36
-rw-r--r--resources/vue/components/blubber/Panel.vue172
-rw-r--r--resources/vue/components/blubber/SearchWidget.vue62
-rw-r--r--resources/vue/components/blubber/SideInfo.vue16
-rw-r--r--resources/vue/components/blubber/Thread.vue252
-rw-r--r--resources/vue/components/blubber/ThreadSubscriber.vue32
-rw-r--r--resources/vue/components/blubber/ThreadsWidget.vue109
-rw-r--r--resources/vue/components/blubber/components.js10
-rw-r--r--resources/vue/plugins/blubber.js63
-rw-r--r--resources/vue/store/blubber.js364
-rw-r--r--templates/blubber/threads-overview.php16
-rw-r--r--tests/jsonapi/BlubberThreadsCreateTest.php6
-rw-r--r--tests/jsonapi/BlubberThreadsIndexTest.php6
-rw-r--r--tests/jsonapi/BlubberThreadsShowTest.php6
-rw-r--r--tests/jsonapi/_bootstrap.php3
46 files changed, 2042 insertions, 1322 deletions
diff --git a/app/controllers/blubber.php b/app/controllers/blubber.php
index d89916c..71d89f3 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 79db849..8acd24b 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 6be9bfd..f790a5d 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 0055f97..e168f4a 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 ff2a03d..045598b 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 bffa13a..bef6327 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 8ab431e..9d4cf67 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 f3222ef..fb8b50d 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 3548645..6d95020 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 0000000..91eb3b8
--- /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 0000000..ee577ce
--- /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 0000000..a85db30
--- /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 0077023..6c0ffe3 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 2a7f660..e959c05 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 3e32665..beaeecb 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 db80023..0000000
--- 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 d0f2f89..52465d2 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 59c0527..db613fd 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 70b78e7..5de07aa 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 a37a466..a7d6f05 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 d169163..44d91bd 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 fad92e2..7888f29 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 1b0e9e7..6c9e7ff 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 2236e1f..0000000
--- 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 15fce7c..0000000
--- 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 dab56cf..0000000
--- 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 c62bf3c..0000000
--- 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 34d935d..88c2834 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 0000000..93d7432
--- /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 0000000..8831978
--- /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 0000000..f4b6aff
--- /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 0000000..3afcddf
--- /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 0000000..12f15a2
--- /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 0000000..0c90f3c
--- /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 0000000..77aa941
--- /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 0000000..2a53031
--- /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 0000000..1bfb8bd
--- /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 0000000..15f91ab
--- /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 0000000..a4040c2
--- /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 0000000..5e732aa
--- /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 0000000..bd1c8e1
--- /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 9f5e1672..0000000
--- 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 4b275d5..17846f8 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 ea665ab..ec2929c 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 856865d..910338d 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 d9c5adc..a6177df 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");