diff --git a/.gitignore b/.gitignore index d5ea474d8f..f6cbe131cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,33 +1,55 @@ # SPDX-FileCopyrightText: 2016 Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later -/assets/ + +# build dirs +/assets /css/*.map -/js/* -/css/* +/js +/css +/build + +# Mac nonsense .DS_Store + +# Win nonsense +Thumbs.db + +# local folders and files +.local + +# caches .sass-cache/ .php_cs.cache .php-cs-fixer.cache .psalm.cache +tests/.phpunit.result.cache + +# logs +npm-debug.log +yarn-error.log + +# IDEs .project/ .idea/ .vscode/ -build/ nbproject/ +*.iml +*.ntvs* +*.suo +*.sln +*.njsproj + +# modules node_modules/ -tests/.phpunit.result.cache -npm-debug.log + +# Composer vendors vendor vendor-bin/**/vendor/ vendor-bin/**/*.lock -update-workflows.sh -yarn-error.log -Thumbs.db + +# local command files +*.sh *.cmd -*.env -*.iml -*.ntvs* -*.njsproj -*.sln -*.suo + +# misc tests/Api diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b7166cf33..167052df38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,14 +20,17 @@ All notable changes to this project will be documented in this file. - Optimized janitor cron - Optimized rebuild command - Optimized poll loading by migrating subqueries to join (#3692) + - Accelerated loading performance of polls - Separated pollGroups from polls (Store, Service, Mapper, ...) - Catch CronJob runs and report as error, but avoid crash at higher thread levels - Changed poll loading triggers (mainly navigation affected) - Added some status to the watchWorker - - removed performance setting in favor of lazy loading participants + - removed performance user setting in favor of lazy loading participants + - reduce noise by avoiding toasts for obvious changes ### Fixes - Fixed broken endpoint for manually calling autoReminderCron + - fix avatar foreground color ## [8.0.6] - 2025-07-03 ### Changes (8.0.6) diff --git a/lib/Command/Share/Add.php b/lib/Command/Share/Add.php index 913cd5eda3..d5a8e0d200 100644 --- a/lib/Command/Share/Add.php +++ b/lib/Command/Share/Add.php @@ -64,7 +64,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $emails = $input->getOption('email'); try { - $poll = $this->pollMapper->find($pollId); + $poll = $this->pollMapper->get($pollId); } catch (DoesNotExistException $e) { $output->writeln('Poll not found.'); return 1; diff --git a/lib/Command/Share/Remove.php b/lib/Command/Share/Remove.php index 10b8d2dcbc..1a59adfaeb 100644 --- a/lib/Command/Share/Remove.php +++ b/lib/Command/Share/Remove.php @@ -65,7 +65,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $emails = $input->getOption('email'); try { - $poll = $this->pollMapper->find($pollId); + $poll = $this->pollMapper->get($pollId); } catch (DoesNotExistException $e) { $output->writeln('Poll not found.'); return 1; diff --git a/lib/Controller/PollController.php b/lib/Controller/PollController.php index 600358c3c7..71f73a19a9 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -102,14 +102,66 @@ public function get(int $pollId): JSONResponse { #[NoAdminRequired] #[FrontpageRoute(verb: 'GET', url: '/poll/{pollId}')] public function getFull(int $pollId): JSONResponse { - return $this->response(fn () => [ - 'poll' => $this->pollService->get($pollId), - 'options' => $this->optionService->list($pollId), - 'votes' => $this->voteService->list($pollId), - 'comments' => $this->commentService->list($pollId), - 'shares' => $this->shareService->list($pollId), - 'subscribed' => $this->subscriptionService->get($pollId), - ]); + return $this->response(fn () => $this->getFullPoll($pollId), Http::STATUS_OK); + + // return $this->response(fn () => [ + // 'poll' => $this->pollService->get($pollId), + // 'options' => $this->optionService->list($pollId), + // 'votes' => $this->voteService->list($pollId), + // 'comments' => $this->commentService->list($pollId), + // 'shares' => $this->shareService->list($pollId), + // 'subscribed' => $this->subscriptionService->get($pollId), + // ]); + } + + private function getFullPoll(int $pollId, bool $withTimings = false): array { + $timerMicro['start'] = microtime(true); + + $poll = $this->pollService->get($pollId); + $timerMicro['poll'] = microtime(true); + + $options = $this->optionService->list($pollId); + $timerMicro['options'] = microtime(true); + + $votes = $this->voteService->list($pollId); + $timerMicro['votes'] = microtime(true); + + $comments = $this->commentService->list($pollId); + $timerMicro['comments'] = microtime(true); + + $shares = $this->shareService->list($pollId); + $timerMicro['shares'] = microtime(true); + + $subscribed = $this->subscriptionService->get($pollId); + $timerMicro['subscribed'] = microtime(true); + + $diffMicro['total'] = microtime(true) - $timerMicro['start']; + $diffMicro['poll'] = $timerMicro['poll'] - $timerMicro['start']; + $diffMicro['options'] = $timerMicro['options'] - $timerMicro['poll']; + $diffMicro['votes'] = $timerMicro['votes'] - $timerMicro['options']; + $diffMicro['comments'] = $timerMicro['comments'] - $timerMicro['votes']; + $diffMicro['shares'] = $timerMicro['shares'] - $timerMicro['comments']; + $diffMicro['subscribed'] = $timerMicro['subscribed'] - $timerMicro['shares']; + + if ($withTimings) { + return [ + 'poll' => $poll, + 'options' => $options, + 'votes' => $votes, + 'comments' => $comments, + 'shares' => $shares, + 'subscribed' => $subscribed, + 'diffMicro' => $diffMicro, + ]; + } + return [ + 'poll' => $poll, + 'options' => $options, + 'votes' => $votes, + 'comments' => $comments, + 'shares' => $shares, + 'subscribed' => $subscribed, + ]; } /** diff --git a/lib/Db/Poll.php b/lib/Db/Poll.php index 7fd7b5ce65..4a00648dce 100644 --- a/lib/Db/Poll.php +++ b/lib/Db/Poll.php @@ -66,8 +66,6 @@ * @method void setVotingVariant(string $value) * * Magic functions for joined columns - * @method int getMinDate() - * @method int getMaxDate() * @method int getShareToken() * @method int getOptionsCount() * @method int getProposalsCount() @@ -168,8 +166,8 @@ class Poll extends EntityWithUser implements JsonSerializable { // joined columns protected ?int $isCurrentUserLocked = 0; - protected int $maxDate = 0; - protected int $minDate = 0; + protected ?int $maxDate = 0; + protected ?int $minDate = 0; protected string $userRole = self::ROLE_NONE; protected string $shareToken = ''; protected ?string $groupShares = ''; @@ -517,6 +515,19 @@ private function getDescriptionSafe(): string { return htmlspecialchars($this->getDescription()); } + private function getMaxDate(): int { + if ($this->maxDate === null) { + return 0; + } + return $this->maxDate; + } + + private function getMinDate(): int { + if ($this->minDate === null) { + return time(); + } + return $this->minDate; + } private function setMiscSettingsArray(array $value): void { $this->setMiscSettings(json_encode($value)); diff --git a/lib/Db/PollMapper.php b/lib/Db/PollMapper.php index 11b39b799f..5049064ba3 100644 --- a/lib/Db/PollMapper.php +++ b/lib/Db/PollMapper.php @@ -35,13 +35,27 @@ public function __construct( * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException if more than one result * @return Poll */ - public function get(int $id, bool $getDeleted = false): Poll { + public function get(int $id, bool $getDeleted = false, bool $withRoles = false): Poll { $qb = $this->db->getQueryBuilder(); - $qb->select('*') - ->from($this->getTableName()) - ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + $qb->select(self::TABLE . '.*') + ->from($this->getTableName(), self::TABLE) + ->where($qb->expr()->eq(self::TABLE . '.id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->groupBy(self::TABLE . '.id'); + if (!$getDeleted) { - $qb->andWhere($qb->expr()->eq('deleted', $qb->expr()->literal(0, IQueryBuilder::PARAM_INT))); + $qb->andWhere($qb->expr()->eq(self::TABLE . '.deleted', $qb->expr()->literal(0, IQueryBuilder::PARAM_INT))); + } + + if ($withRoles) { + $pollGroupsAlias = 'poll_groups'; + $currentUserId = $this->userSession->getCurrentUserId(); + // $this->joinOptions($qb, self::TABLE); + $this->joinUserRole($qb, self::TABLE, $currentUserId); + $this->joinGroupShares($qb, self::TABLE); + $this->joinPollGroups($qb, self::TABLE, $pollGroupsAlias); + $this->joinPollGroupShares($qb, $pollGroupsAlias, $currentUserId, $pollGroupsAlias); + // $this->joinVotesCount($qb, self::TABLE, $currentUserId); + // $this->joinParticipantsCount($qb, self::TABLE); } return $this->findEntity($qb); } @@ -184,19 +198,19 @@ public function deleteByUserId(string $userId): void { * Build the enhanced query with joined tables */ protected function buildQuery(): IQueryBuilder { - $currentUserId = $this->userSession->getCurrentUserId(); $qb = $this->db->getQueryBuilder(); $qb->select(self::TABLE . '.*') ->from($this->getTableName(), self::TABLE) ->groupBy(self::TABLE . '.id'); + $currentUserId = $this->userSession->getCurrentUserId(); $pollGroupsAlias = 'poll_groups'; $this->joinOptions($qb, self::TABLE); $this->joinUserRole($qb, self::TABLE, $currentUserId); $this->joinGroupShares($qb, self::TABLE); $this->joinPollGroups($qb, self::TABLE, $pollGroupsAlias); - $this->joinUserSharesfromPollGroups($qb, $pollGroupsAlias, $currentUserId, $pollGroupsAlias); + $this->joinPollGroupShares($qb, $pollGroupsAlias, $currentUserId, $pollGroupsAlias); $this->joinVotesCount($qb, self::TABLE, $currentUserId); $this->joinParticipantsCount($qb, self::TABLE); return $qb; @@ -299,7 +313,7 @@ protected function joinPollGroups( * Supported share types are User and Admin * Groups, Teams will not work atm. */ - protected function joinUserSharesfromPollGroups( + protected function joinPollGroupShares( IQueryBuilder $qb, string $fromAlias, string $currentUserId, @@ -338,13 +352,16 @@ protected function joinOptions( string $fromAlias, string $joinAlias = 'options', ): void { + // add highest option date + $qb->addSelect($qb->createFunction('MAX(' . $joinAlias . '.timestamp) AS max_date')); + + // add lowest option date + $qb->addSelect($qb->createFunction('MIN(' . $joinAlias . '.timestamp) AS min_date')); - $zero = $qb->expr()->literal(0, IQueryBuilder::PARAM_INT); - $saveMin = $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT); + // add number of options with an owner (results in number of proposals) + $qb->addSelect($qb->createFunction('COUNT(DISTINCT(CASE WHEN ' . $joinAlias . '.owner != \'\' THEN 1 END)) AS proposals_count')); - $qb->addSelect($qb->createFunction('coalesce(MAX(' . $joinAlias . '.timestamp), ' . $zero . ') AS max_date')) - ->addSelect($qb->createFunction('coalesce(MIN(' . $joinAlias . '.timestamp), ' . $saveMin . ') AS min_date')) - ->addSelect($qb->createFunction('COUNT(DISTINCT(CASE WHEN ' . $joinAlias . '.owner != \'\' THEN 1 END)) AS proposals_count')); + // count number of options by counting unique ids $qb->selectAlias($qb->func()->count($joinAlias . '.id'), 'optionsCount'); $qb->leftJoin( diff --git a/lib/Helper/Container.php b/lib/Helper/Container.php index fba6ae6c97..43528876a8 100644 --- a/lib/Helper/Container.php +++ b/lib/Helper/Container.php @@ -38,7 +38,7 @@ public static function getPoll(int $pollId, bool $getDeleted = false): Poll { } public static function queryPoll(int $pollId): Poll { - return Server::get(PollMapper::class)->find($pollId); + return Server::get(PollMapper::class)->get($pollId); } public static function findShare(int $pollId, string $userId): Share { diff --git a/lib/Service/CommentService.php b/lib/Service/CommentService.php index e73617230e..0bc5d111ad 100644 --- a/lib/Service/CommentService.php +++ b/lib/Service/CommentService.php @@ -36,11 +36,11 @@ public function __construct( */ public function list(int $pollId): array { try { - $this->pollMapper->find($pollId)->request(Poll::PERMISSION_COMMENT_ADD); + $this->pollMapper->get($pollId, withRoles: true)->request(Poll::PERMISSION_COMMENT_ADD); } catch (Exception $e) { return []; } - $this->pollMapper->find($pollId)->request(Poll::PERMISSION_COMMENT_ADD); + $this->pollMapper->get($pollId, withRoles: true)->request(Poll::PERMISSION_COMMENT_ADD); $comments = $this->commentMapper->findByPoll($pollId); // treat comments from the same user within 5 minutes as grouped comments @@ -66,7 +66,7 @@ public function list(int $pollId): array { * Add comment */ public function add(string $message, int $pollId, ?bool $confidential = false): Comment { - $poll = $this->pollMapper->find($pollId); + $poll = $this->pollMapper->get($pollId, withRoles: true); $poll->request(Poll::PERMISSION_COMMENT_ADD); if ($poll->getForceConfidentialComments()) { @@ -104,7 +104,7 @@ public function delete(int $commentId, bool $restore = false): Comment { $this->comment = $this->commentMapper->find($commentId); if (!$this->comment->getCurrentUserIsEntityUser()) { - $this->pollMapper->find($this->comment->getPollId())->request(Poll::PERMISSION_COMMENT_DELETE); + $this->pollMapper->get($this->comment->getPollId(), withRoles: true)->request(Poll::PERMISSION_COMMENT_DELETE); } $this->comment->setDeleted($restore ? 0 : time()); diff --git a/lib/Service/MailService.php b/lib/Service/MailService.php index 4e8d214ae7..248f95060a 100644 --- a/lib/Service/MailService.php +++ b/lib/Service/MailService.php @@ -227,7 +227,7 @@ public function sendAutoReminder(): void { * Send a confirmation mail for the poll to all participants */ public function sendConfirmations(int $pollId): SentResult { - $this->pollMapper->find($pollId)->request(Poll::PERMISSION_POLL_EDIT); + $this->pollMapper->get($pollId, withRoles: true)->request(Poll::PERMISSION_POLL_EDIT); $sentResult = new SentResult(); $participants = $this->userMapper->getParticipants($pollId); diff --git a/lib/Service/OptionService.php b/lib/Service/OptionService.php index f3fd93beaa..9fce6ac95a 100644 --- a/lib/Service/OptionService.php +++ b/lib/Service/OptionService.php @@ -198,7 +198,7 @@ public function delete(int $optionId, bool $restore = false): Option { $option = $this->optionMapper->find($optionId); if (!$option->getCurrentUserIsEntityUser()) { - $this->pollMapper->find($option->getPollId())->request(Poll::PERMISSION_OPTION_DELETE); + $this->pollMapper->get($option->getPollId(), withRoles: true)->request(Poll::PERMISSION_OPTION_DELETE); } $option->setDeleted($restore ? 0 : time()); @@ -314,8 +314,8 @@ public function shift(int $pollId, int $step, string $unit): array { * Copy options from $fromPoll to $toPoll */ public function clone(int $fromPollId, int $toPollId): void { - $this->pollMapper->find($fromPollId)->request(Poll::PERMISSION_POLL_VIEW); - $this->pollMapper->find($toPollId)->request(Poll::PERMISSION_OPTION_ADD); + $this->pollMapper->get($fromPollId, withRoles: true)->request(Poll::PERMISSION_POLL_VIEW); + $this->pollMapper->get($toPollId, withRoles: true)->request(Poll::PERMISSION_OPTION_ADD); foreach ($this->optionMapper->findByPoll($fromPollId) as $origin) { $option = new Option(); @@ -426,7 +426,7 @@ private function moveModifier(int $moveFrom, int $moveTo, int $currentPosition): */ private function getPoll(int $pollId, string $permission = Poll::PERMISSION_POLL_VIEW): void { if ($this->poll->getId() !== $pollId) { - $this->poll = $this->pollMapper->find($pollId); + $this->poll = $this->pollMapper->get($pollId, withRoles: true); } $this->poll->request($permission); } diff --git a/lib/Service/PollGroupService.php b/lib/Service/PollGroupService.php index e9927b342f..0ca2f342c7 100644 --- a/lib/Service/PollGroupService.php +++ b/lib/Service/PollGroupService.php @@ -64,7 +64,7 @@ public function addPollToPollGroup( ?int $pollGroupId = null, ?string $pollGroupName = null, ): PollGroup { - $poll = $this->pollMapper->find($pollId); + $poll = $this->pollMapper->get($pollId, withRoles: true); $poll->request(Poll::PERMISSION_POLL_EDIT); // Without poll group id, we create a new poll group @@ -112,7 +112,7 @@ public function removePollFromPollGroup( int $pollId, int $pollGroupId, ): ?PollGroup { - $poll = $this->pollMapper->find($pollId); + $poll = $this->pollMapper->get($pollId, withRoles: true); $poll->request(Poll::PERMISSION_POLL_EDIT); $pollGroup = $this->pollGroupMapper->find($pollGroupId); diff --git a/lib/Service/PollService.php b/lib/Service/PollService.php index 0e2dd07838..84a1566740 100644 --- a/lib/Service/PollService.php +++ b/lib/Service/PollService.php @@ -139,7 +139,7 @@ public function takeover(int $pollId, ?UserBase $targetUser = null): Poll { */ public function transferPoll(int|Poll $poll, string|UserBase $targetUser): Poll { if (!($poll instanceof Poll)) { - $poll = $this->pollMapper->find($poll); + $poll = $this->pollMapper->get($poll, withRoles: true); } $poll->request(Poll::PERMISSION_POLL_CHANGE_OWNER); @@ -168,9 +168,13 @@ public function transferPoll(int|Poll $poll, string|UserBase $targetUser): Poll * get poll configuration * @return Poll */ - public function get(int $pollId) { + public function get(int $pollId, $lightweight = false) { try { - $this->poll = $this->pollMapper->find($pollId); + if ($lightweight) { + $this->poll = $this->pollMapper->get($pollId, withRoles: true); + } else { + $this->poll = $this->pollMapper->find($pollId); + } $this->poll->request(Poll::PERMISSION_POLL_VIEW); return $this->poll; } catch (DoesNotExistException $e) { @@ -180,7 +184,7 @@ public function get(int $pollId) { public function getPollOwnerFromDB(int $pollId): UserBase { try { - $poll = $this->pollMapper->find($pollId); + $poll = $this->pollMapper->get($pollId, withRoles: true); return $poll->getUser(); } catch (DoesNotExistException $e) { throw new NotFoundException('Poll not found'); @@ -341,7 +345,7 @@ public function toggleArchive(int $pollId): Poll { */ public function delete(int $pollId): Poll { try { - $this->poll = $this->pollMapper->find($pollId); + $this->poll = $this->pollMapper->get($pollId, withRoles: true); } catch (DoesNotExistException $e) { throw new AlreadyDeletedException('Poll not found, assume already deleted'); } @@ -358,7 +362,7 @@ public function delete(int $pollId): Poll { * @return Poll */ public function close(int $pollId): Poll { - $this->pollMapper->find($pollId)->request(Poll::PERMISSION_POLL_EDIT); + $this->pollMapper->get($pollId, withRoles: true)->request(Poll::PERMISSION_POLL_EDIT); return $this->toggleClose($pollId, time() - 5); } @@ -367,7 +371,7 @@ public function close(int $pollId): Poll { * @return Poll */ public function reopen(int $pollId): Poll { - $this->pollMapper->find($pollId)->request(Poll::PERMISSION_POLL_EDIT); + $this->pollMapper->get($pollId, withRoles: true)->request(Poll::PERMISSION_POLL_EDIT); return $this->toggleClose($pollId, 0); } @@ -396,7 +400,7 @@ private function toggleClose(int $pollId, int $expiry): Poll { * @return Poll */ public function clone(int $pollId): Poll { - $origin = $this->pollMapper->find($pollId); + $origin = $this->pollMapper->get($pollId, withRoles: true); $origin->request(Poll::PERMISSION_POLL_VIEW); $this->appSettings->getPollCreationAllowed(); @@ -428,7 +432,7 @@ public function clone(int $pollId): Poll { * */ public function getParticipantsEmailAddresses(int $pollId): array { - $this->poll = $this->pollMapper->find($pollId); + $this->poll = $this->pollMapper->get($pollId, withRoles: true); $this->poll->request(Poll::PERMISSION_POLL_EDIT); $votes = $this->voteMapper->findParticipantsByPoll($this->poll->getId()); diff --git a/lib/Service/ShareService.php b/lib/Service/ShareService.php index a2380cba43..63d8af9b20 100644 --- a/lib/Service/ShareService.php +++ b/lib/Service/ShareService.php @@ -88,7 +88,7 @@ public function __construct( public function list(int $pollOrPollGroupId, string $purpose = 'poll'): array { try { if ($purpose === 'poll') { - $poll = $this->pollMapper->find($pollOrPollGroupId); + $poll = $this->pollMapper->get($pollOrPollGroupId, withRoles: true); $poll->request(Poll::PERMISSION_POLL_EDIT); $this->shares = $this->shareMapper->findByPoll($pollOrPollGroupId, $poll->getPollGroups()); } else { @@ -113,7 +113,7 @@ public function list(int $pollOrPollGroupId, string $purpose = 'poll'): array { */ public function listNotInvited(int $pollId): array { try { - $this->pollMapper->find($pollId)->request(Poll::PERMISSION_POLL_EDIT); + $this->pollMapper->get($pollId, withRoles: true)->request(Poll::PERMISSION_POLL_EDIT); $this->shares = $this->shareMapper->findByPollNotInvited($pollId); } catch (ForbiddenException $e) { return []; @@ -187,7 +187,7 @@ public function get(string $token): Share { */ public function setType(string $token, string $type): Share { $this->share = $this->shareMapper->findByToken($token); - $this->pollMapper->find($this->share->getPollId())->request(Poll::PERMISSION_POLL_EDIT); + $this->pollMapper->get($this->share->getPollId(), withRoles: true)->request(Poll::PERMISSION_POLL_EDIT); // ATM only type user can transform to type admin and vice versa if (($type === Share::TYPE_ADMIN && $this->share->getType() === Share::TYPE_USER) @@ -206,7 +206,7 @@ public function setType(string $token, string $type): Share { public function setPublicPollEmail(string $token, string $value): Share { try { $this->share = $this->shareMapper->findByToken($token); - $this->pollMapper->find($this->share->getPollId())->request(Poll::PERMISSION_POLL_EDIT); + $this->pollMapper->get($this->share->getPollId(), withRoles: true)->request(Poll::PERMISSION_POLL_EDIT); $this->share->setPublicPollEmail($value); $this->share = $this->shareMapper->update($this->share); } catch (ShareNotFoundException $e) { @@ -269,7 +269,7 @@ public function setLabel(string $label, string $token): Share { $this->share = $this->shareMapper->findByToken($token); if ($this->share->getType() === Share::TYPE_PUBLIC) { - $this->pollMapper->find($this->share->getPollId())->request(Poll::PERMISSION_POLL_EDIT); + $this->pollMapper->get($this->share->getPollId(), withRoles: true)->request(Poll::PERMISSION_POLL_EDIT); $this->share->setLabel($label); // overwrite any possible displayName @@ -435,7 +435,7 @@ public function delete(Share $share, bool $restore = false): Share { if (!$share->getPollId() && $share->getGroupId()) { $this->pollGroupMapper->find($share->getGroupId())->request(PollGroup::PERMISSION_POLL_GROUP_EDIT); } else { - $this->pollMapper->find($share->getPollId())->request(Poll::PERMISSION_POLL_EDIT); + $this->pollMapper->get($share->getPollId(), withRoles: true)->request(Poll::PERMISSION_POLL_EDIT); } $share->setDeleted($restore ? 0 : time()); @@ -464,7 +464,7 @@ public function lockByToken(string $token, bool $unlock = false): Share { * @param bool $unlock Set true, if share is to be unlocked */ private function lock(Share $share, bool $unlock = false): Share { - $this->pollMapper->find($share->getPollId())->request(Poll::PERMISSION_POLL_EDIT); + $this->pollMapper->get($share->getPollId(), withRoles: true)->request(Poll::PERMISSION_POLL_EDIT); $share->setLocked($unlock ? 0 : time()); $this->shareMapper->update($share); @@ -586,7 +586,7 @@ public function add( string $purpose = 'poll', ): Share { if ($purpose === 'poll') { - $poll = $this->pollMapper->find($pollOrPollGroupId); + $poll = $this->pollMapper->get($pollOrPollGroupId, withRoles: true); $poll->request(Poll::PERMISSION_POLL_EDIT); $poll->request(Poll::PERMISSION_SHARE_ADD); diff --git a/lib/Service/SubscriptionService.php b/lib/Service/SubscriptionService.php index 89b8383e51..7c4cfe7b7e 100644 --- a/lib/Service/SubscriptionService.php +++ b/lib/Service/SubscriptionService.php @@ -27,7 +27,7 @@ public function __construct( } public function get(int $pollId): bool { - $this->pollMapper->find($pollId)->request(Poll::PERMISSION_POLL_VIEW); + $this->pollMapper->get($pollId, withRoles: true)->request(Poll::PERMISSION_POLL_VIEW); try { $this->subscriptionMapper->findByPollAndUser($pollId, $this->userSession->getCurrentUserId()); @@ -52,7 +52,8 @@ public function set(bool $setToSubscribed, int $pollId): bool { } } else { try { - $this->pollMapper->find($pollId)->request(Poll::PERMISSION_POLL_SUBSCRIBE); + // $this->pollMapper->get($pollId, withRoles: true); + $this->pollMapper->get($pollId, withRoles: true)->request(Poll::PERMISSION_POLL_SUBSCRIBE); $this->add($pollId, $this->userSession->getCurrentUserId()); } catch (ForbiddenException $e) { return false; diff --git a/lib/Service/VoteService.php b/lib/Service/VoteService.php index f742d12fa3..38b4f19bb8 100644 --- a/lib/Service/VoteService.php +++ b/lib/Service/VoteService.php @@ -39,7 +39,7 @@ public function __construct( * @return Vote[] */ public function list(int $pollId): array { - $poll = $this->pollMapper->find($pollId); + $poll = $this->pollMapper->get($pollId, withRoles: true); $poll->request(Poll::PERMISSION_POLL_VIEW); if (!$poll->getIsAllowed(Poll::PERMISSION_POLL_RESULTS_VIEW)) { @@ -85,7 +85,7 @@ public function set(Option|int $optionOrOptionIdoptionId, string $setTo): ?Vote } else { $option = $this->optionMapper->find($optionOrOptionIdoptionId); } - $poll = $this->pollMapper->find($option->getPollId()); + $poll = $this->pollMapper->get($option->getPollId(), withRoles: true); $poll->request(Poll::PERMISSION_VOTE_EDIT); if ($option->getIsLocked()) { @@ -150,7 +150,7 @@ public function deleteUserFromPoll(int $pollId, string $userId = '', bool $delet $checkRight = Poll::PERMISSION_VOTE_EDIT; } - $this->pollMapper->find($pollId)->request($checkRight); + $this->pollMapper->get($pollId, withRoles: true)->request($checkRight); return $this->delete($pollId, $userId, $deleteOnlyOrphaned); } diff --git a/lib/Service/WatchService.php b/lib/Service/WatchService.php index 4de1130231..a1bdf3ccc5 100644 --- a/lib/Service/WatchService.php +++ b/lib/Service/WatchService.php @@ -35,7 +35,7 @@ public function __construct( */ public function watchUpdates(int $pollId, ?int $offset = null): array { if ($pollId) { - $this->pollMapper->find($pollId)->request(Poll::PERMISSION_POLL_VIEW); + $this->pollMapper->get($pollId, withRoles: true)->request(Poll::PERMISSION_POLL_VIEW); } $start = time(); diff --git a/src/App.vue b/src/App.vue index a41cd6c228..039404df0a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,7 +11,6 @@ import { subscribe, unsubscribe } from '@nextcloud/event-bus' import NcContent from '@nextcloud/vue/components/NcContent' import UserSettingsDlg from './components/Settings/UserSettingsDlg.vue' -import LoadingOverlay from './components/Base/modules/LoadingOverlay.vue' import { usePollWatcher } from './composables/usePollWatcher' @@ -35,7 +34,6 @@ const pollStore = usePollStore() const pollGroupsStore = usePollGroupsStore() const transitionClass = ref('transitions-active') -const loading = ref(false) const appClass = computed(() => [ transitionClass.value, @@ -118,7 +116,6 @@ onUnmounted(() => { - diff --git a/src/components/Base/modules/IntersectionObserver.vue b/src/components/Base/modules/IntersectionObserver.vue index 7b5cdf1c2e..92c1bd4153 100644 --- a/src/components/Base/modules/IntersectionObserver.vue +++ b/src/components/Base/modules/IntersectionObserver.vue @@ -5,6 +5,7 @@ diff --git a/src/components/Options/OptionsDateAddDialog.vue b/src/components/Options/OptionsDateAddDialog.vue index 31fc847334..e72231c44c 100644 --- a/src/components/Options/OptionsDateAddDialog.vue +++ b/src/components/Options/OptionsDateAddDialog.vue @@ -5,7 +5,7 @@ diff --git a/src/components/User/UserItem.vue b/src/components/User/UserItem.vue index 68ff7e5578..b3c4523043 100644 --- a/src/components/User/UserItem.vue +++ b/src/components/User/UserItem.vue @@ -335,6 +335,7 @@ function showMenu() { .user-item__avatar .material-design-icon { background-color: var(--color-primary-element); border-radius: 50%; + color: var(--color-primary-element-text); } .user-item__name { diff --git a/src/components/VoteTable/VoteTable.vue b/src/components/VoteTable/VoteTable.vue index 5f696d63c2..de1218d740 100644 --- a/src/components/VoteTable/VoteTable.vue +++ b/src/components/VoteTable/VoteTable.vue @@ -12,7 +12,7 @@ import { useVotesStore } from '../../stores/votes.ts' import { NcButton } from '@nextcloud/vue' import SortNameIcon from 'vue-material-design-icons/SortAlphabeticalDescending.vue' -import { computed } from 'vue' +import { computed, nextTick, ref } from 'vue' import { getCurrentUser } from '@nextcloud/auth' import Counter from '../Options/Counter.vue' import CalendarPeek from '../Calendar/CalendarPeek.vue' @@ -30,6 +30,7 @@ const pollStore = usePollStore() const optionsStore = useOptionsStore() const votesStore = useVotesStore() const preferencesStore = usePreferencesStore() +const chunksLoading = ref(false) const tableStyle = computed(() => ({ '--participants-count': `${pollStore.safeParticipants.length}`, @@ -46,9 +47,15 @@ const showCalendarPeek = computed( /** * */ -async function loadMore() { +function loadMore() { try { - votesStore.addChunk() + chunksLoading.value = true + nextTick(() => { + votesStore.addChunk() + }) + nextTick(() => { + chunksLoading.value = false + }) } catch { showError(t('polls', 'Error loading more participants')) } @@ -103,7 +110,7 @@ async function loadMore() {
{{ diff --git a/src/router.ts b/src/router.ts index 97ee88ab5e..c0bec0f850 100644 --- a/src/router.ts +++ b/src/router.ts @@ -12,76 +12,82 @@ import { import { getCurrentUser } from '@nextcloud/auth' import { generateUrl } from '@nextcloud/router' import { getCookieValue, Logger, setCookie } from './helpers/index.ts' -import { PublicAPI } from './Api/index.ts' import { loadContext } from './composables/context.ts' -import Vote from './views/Vote.vue' -import SideBar from './views/SideBar.vue' +import { AxiosError } from 'axios' +import Navigation from './views/Navigation.vue' + +import Combo from './views/Combo.vue' +import Forbidden from './views/Forbidden.vue' import List from './views/PollList.vue' import NotFound from './views/NotFound.vue' +import Vote from './views/Vote.vue' + +import SideBar from './views/SideBar.vue' +import SideBarPollGroup from './views/SideBarPollGroup.vue' import SideBarCombo from './views/SideBarCombo.vue' -import Navigation from './views/Navigation.vue' -import Combo from './views/Combo.vue' + import { usePollStore } from './stores/poll.ts' -import { FilterType, usePollsStore } from './stores/polls.ts' +import { FilterType } from './stores/polls.ts' import { useSessionStore } from './stores/session.ts' -import SideBarPollGroup from './views/SideBarPollGroup.vue' -import { useSharesStore } from './stores/shares.ts' -import { AxiosError } from 'axios' -import Forbidden from './views/Forbidden.vue' +import { ShareType } from './stores/shares.ts' async function validateToken(to: RouteLocationNormalized) { - if (getCurrentUser()) { - try { - const response = await PublicAPI.getShare(to.params.token as string) - // if the user is logged in, we diretly route to - // the internal vote page + const sessionStore = useSessionStore() + + try { + await sessionStore.loadShare() + + // if the user is logged in, reroute to the vote page + if (getCurrentUser()) { return { name: 'vote', params: { - id: response.data.share.pollId, + id: sessionStore.share.pollId, }, } - } catch (error) { - // in case of an error, reroute to the not found page - return { - name: 'notfound', + } + } catch (error) { + if (getCurrentUser()) { + if ((error as AxiosError).response?.status === 403) { + // User has no access + return { name: 'forbidden' } } + // in case of other errors, reroute internal user to the not found page + return { name: 'notfound' } } - } - // continue for external users - try { - // first validate the existence of the public token - await PublicAPI.getShare(to.params.token as string) - } catch (error) { - // in case of an error, reroute to the login page + // external users will get redirected to the login page window.location.replace(generateUrl('login')) } - // then look for an existing personal token from - // the user's client stored cookie - // matching the public token - const personalToken = getCookieValue(to.params.token as string) + // Continue for external users + // + if (sessionStore.share.type === ShareType.Public) { + // Check, if user has a personal token from the user's client stored cookie + // matching the public token + const personalToken = getCookieValue(to.params.token as string) - if (personalToken && personalToken !== to.params.token) { - // participant has already access to the poll and a private token - // extend expiry time for 30 days after successful access - const cookieExpiration = 30 * 24 * 60 * 1000 - setCookie(to.params.token, personalToken, cookieExpiration) + if (personalToken) { + // participant has already access to the poll and a private token + // extend expiry time for 30 days after successful access + const cookieExpiration = 30 * 24 * 60 * 1000 + setCookie(to.params.token as string, personalToken, cookieExpiration) - // reroute to the public vote page using the personal token - return { - name: 'publicVote', - params: { - token: personalToken, - }, + // reroute to the public vote page using the personal token + return { + name: 'publicVote', + params: { + token: personalToken, + }, + } } } - // if no private token is found, load the poll + // finally load the poll const pollStore = usePollStore() - await pollStore.load() + + pollStore.load() } const routes: RouteRecordRaw[] = [ @@ -196,18 +202,15 @@ const router = createRouter({ router.beforeEach( async (to: RouteLocationNormalized, from: RouteLocationNormalized) => { const sessionStore = useSessionStore() - const pollStore = usePollStore() - const pollsStore = usePollsStore() - const sharesStore = useSharesStore() + // if the previous and the requested routes have the same name and + // the watcher is active, we can do a cheap loading const cheapLoading = - sessionStore.watcher.mode !== 'noPolling' + to.name === from.name + && sessionStore.watcher.mode !== 'noPolling' && sessionStore.watcher.status !== 'stopped' - && to.name === from.name // first load app context -> session and preferences - // await loading until further execution to ensure, - // the context is loaded properly try { await loadContext(to, cheapLoading) } catch (error) { @@ -224,43 +227,6 @@ router.beforeEach( name: 'notfound', } } - - try { - // for public pages we need to load the share first - if (to.meta.publicPage) { - await sessionStore.loadShare() - } - - // vote pages load the particular poll - // or reset the poll store if not a vote page - if (to.meta.votePage) { - await pollStore.load() - } else { - pollStore.resetPoll() - } - - // load polls at least for navigation - if (!to.meta.publicPage && !cheapLoading) { - await pollsStore.load() - } - - // group pages need shares for the current poll group - if (to.meta.groupPage) { - sharesStore.load('pollGroup') - } - } catch (error) { - Logger.warn('Could not load poll', { error }) - if ((error as AxiosError).response?.status === 403) { - // User has no access - return { - name: 'forbidden', - } - } - // else let's pretend, the poll does not exist (what will be probably the case) - return { - name: 'notfound', - } - } }, ) diff --git a/src/stores/poll.ts b/src/stores/poll.ts index 8d349360b5..f501913215 100644 --- a/src/stores/poll.ts +++ b/src/stores/poll.ts @@ -410,20 +410,25 @@ export const usePollStore = defineStore('poll', { subscriptionStore.reset() }, - async load(): Promise { + async load(pollId: number | null = null): Promise { const votesStore = useVotesStore() const sessionStore = useSessionStore() const optionsStore = useOptionsStore() const sharesStore = useSharesStore() const commentsStore = useCommentsStore() const subscriptionStore = useSubscriptionStore() + + this.meta.status = StatusResults.Loading + try { const response = await (() => { if (sessionStore.route.name === 'publicVote') { return PublicAPI.getPoll(sessionStore.route.params.token) } if (sessionStore.route.name === 'vote') { - return PollsAPI.getFullPoll(sessionStore.currentPollId) + return PollsAPI.getFullPoll( + pollId ?? sessionStore.currentPollId, + ) } return null })() @@ -439,10 +444,13 @@ export const usePollStore = defineStore('poll', { sharesStore.shares = response.data.shares commentsStore.comments = response.data.comments subscriptionStore.subscribed = response.data.subscribed + + this.meta.status = StatusResults.Loaded } catch (error) { if ((error as AxiosError)?.code === 'ERR_CANCELED') { return } + this.meta.status = StatusResults.Error Logger.error('Error loading poll', { error }) throw error } diff --git a/src/stores/polls.ts b/src/stores/polls.ts index 76d0cf6872..4f19fc3642 100644 --- a/src/stores/polls.ts +++ b/src/stores/polls.ts @@ -93,7 +93,7 @@ export const sortTitlesMapping: { [key in SortType]: string } = { interaction: t('polls', 'Last interaction'), } -export const pollCategories: PollCategoryList = { +const pollCategories: PollCategoryList = { [FilterType.Relevant]: { id: FilterType.Relevant, title: t('polls', 'Relevant'), @@ -223,7 +223,7 @@ export const usePollsStore = defineStore('polls', { loaded: 1, }, maxPollsInNavigation: 6, - status: StatusResults.Loaded, + status: StatusResults.None, }, sort: { by: SortType.Created, @@ -353,12 +353,24 @@ export const usePollsStore = defineStore('polls', { * This will also set the `meta.status` to `Loading` while the request is in progress, * and to `Loaded` or `Error` when the request is finished. * + * @param {boolean} forced - If false, loading polls will only be done, when the status is not `Loaded`. * @throws {Error} If the request fails and is not canceled. * @return {Promise} */ - async load(): Promise { + async load(forced: boolean = true): Promise { const pollGroupsStore = usePollGroupsStore() + if ( + this.meta.status === StatusResults.Loading + || (!forced && this.meta.status === StatusResults.Loaded) + ) { + Logger.debug('Polls already loaded or loading, skipping load', { + status: this.meta.status, + forced, + }) + return + } + this.meta.status = StatusResults.Loading try { diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index ba4243915c..2e58a9f99f 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -31,12 +31,11 @@ const pollsStore = usePollsStore() */ function loadPolls(): void { Logger.debug('Loading polls in dashboard widget') - pollsStore - .load() - .then(() => null) - .catch(() => { - showError(t('polls', 'Error loading poll list')) - }) + try { + pollsStore.load() + } catch (error) { + showError(t('polls', 'Error setting dashboard list')) + } } onMounted(() => { diff --git a/src/views/Navigation.vue b/src/views/Navigation.vue index 70a470a87b..34cd0fad8e 100644 --- a/src/views/Navigation.vue +++ b/src/views/Navigation.vue @@ -4,7 +4,7 @@ --> +