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