diff --git a/cypress/e2e/dashboard-widget.spec.js b/cypress/e2e/dashboard-widget.spec.js new file mode 100644 index 000000000..c9535e531 --- /dev/null +++ b/cypress/e2e/dashboard-widget.spec.js @@ -0,0 +1,49 @@ +/** + * @copyright Copyright (c) 2023 Jonas + * + * @author Jonas + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +/** + * Tests for Collectives dashboard widget. + */ + +describe('Collectives dashboard widget', function() { + if (Cypress.env('ncVersion') !== 'stable25') { + describe('Open dashboard widget', function() { + before(function() { + cy.loginAs('bob') + cy.enableDashboardWidget('collectives-recent-pages') + cy.visit('apps/collectives') + cy.deleteAndSeedCollective('Dashboard Collective1') + cy.seedPage('Page 1', '', 'Readme.md') + }) + it('Lists pages in the dashboard widget', function() { + cy.visit('/apps/dashboard/') + cy.get('.panel--header') + .contains('Recent pages') + cy.get('.panel--content').as('panelContent') + cy.get('@panelContent') + .find('li').should('contain', 'Landing page') + cy.get('@panelContent') + .find('li').should('contain', 'Page 1') + }) + }) + } +}) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 79975e4ae..1e82e3791 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -99,6 +99,20 @@ Cypress.Commands.add('setAppEnabled', (appName, value = true) => { }) }) +/** + * Enable dashboard widget + */ +Cypress.Commands.add('enableDashboardWidget', (widgetName) => { + cy.request('/csrftoken').then(({ body }) => { + const requesttoken = body.token + const api = `${Cypress.env('baseUrl')}/index.php/apps/dashboard/layout` + return axios.post(api, + { layout: widgetName }, + { headers: { requesttoken } }, + ) + }) +}) + /** * First delete, then seed a collective (to start fresh) */ diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 0b844d08d..c77f4ecb9 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -7,6 +7,7 @@ use Closure; use OCA\Circles\Events\CircleDestroyedEvent; use OCA\Collectives\CacheListener; +use OCA\Collectives\Dashboard\RecentPagesWidget; use OCA\Collectives\Db\CollectiveMapper; use OCA\Collectives\Db\PageMapper; use OCA\Collectives\Fs\UserFolderHelper; @@ -34,6 +35,7 @@ use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Collaboration\Reference\RenderReferenceEvent; +use OCP\Dashboard\IAPIWidgetV2; use OCP\Files\Config\IMountProviderCollection; use OCP\Files\IMimeTypeLoader; use OCP\IConfig; @@ -111,6 +113,10 @@ public function register(IRegistrationContext $context): void { $cacheListener = $this->getContainer()->get(CacheListener::class); $cacheListener->listen(); + + if (\interface_exists(IAPIWidgetV2::class)) { + $context->registerDashboardWidget(RecentPagesWidget::class); + } } public function boot(IBootcontext $context): void { diff --git a/lib/Dashboard/RecentPagesWidget.php b/lib/Dashboard/RecentPagesWidget.php new file mode 100644 index 000000000..f04f97e1e --- /dev/null +++ b/lib/Dashboard/RecentPagesWidget.php @@ -0,0 +1,129 @@ +recentPagesService = $recentPagesService; + $this->userSession = $userSession; + $this->urlGenerator = $urlGenerator; + $this->l10n = $l10n; + } + + public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems { + if (!($user = $this->userSession->getUser())) { + return new WidgetItems(); + } + + $recentPages = $this->recentPagesService->forUser($user, self::MAX_ITEMS); + + $items = []; + foreach ($recentPages as $recentPage) { + $items[] = new WidgetItem( + $recentPage->getTitle(), + $recentPage->getCollectiveName(), + $recentPage->getPageUrl(), + 'data:image/svg+xml;base64,' . base64_encode($this->getEmojiAvatar($recentPage->getEmoji() ?: '🗒')), + (string)$recentPage->getTimestamp() + ); + } + + return new WidgetItems($items, $this->l10n->t('Add a collective')); + } + + protected function getFallbackDataIcon(): string { + // currently unused. Was an attempt to use the text icon which is also the fallback + // in Collectives itself. Probably because it is not really square, it is being + // rendered too dominant, compared to regular emojis + return ' +'; + } + + public function getReloadInterval(): int { + return self::REFRESH_INTERVAL_IN_SECS; + } + + public function getId(): string { + return 'collectives-recent-pages'; + } + + public function getTitle(): string { + return $this->l10n->t('Recent pages'); + } + + public function getOrder(): int { + return 6; + } + + public function getIconClass(): string { + return 'icon-collectives'; + } + + public function getUrl(): ?string { + return $this->urlGenerator->linkToRoute('collectives.collective.index'); + } + + public function load(): void { + } + + /** + * shamelessly copied from @nickvergessen​'s work at Talk + * @see https://github.com/nextcloud/spreed/blob/1e5c84ac14fbd1840c970ee7759e7bbdfbcba1a2/lib/Service/AvatarService.php#L174-L192 + */ + private string $svgTemplate = ' + + + {letter} + '; + + /** + * shamelessly copied from @nickvergessen​'s work at Talk + * @see https://github.com/nextcloud/spreed/blob/1e5c84ac14fbd1840c970ee7759e7bbdfbcba1a2/lib/Service/AvatarService.php#L240-L264 + */ + protected function getEmojiAvatar(string $emoji, string $fillColor = '00000000'): string { + return str_replace([ + '{letter}', + '{fill}', + '{font}', + ], [ + $emoji, + $fillColor, + implode(',', [ + "'Segoe UI'", + 'Roboto', + 'Oxygen-Sans', + 'Cantarell', + 'Ubuntu', + "'Helvetica Neue'", + 'Arial', + 'sans-serif', + "'Noto Color Emoji'", + "'Apple Color Emoji'", + "'Segoe UI Emoji'", + "'Segoe UI Symbol'", + "'Noto Sans'", + ]), + ], $this->svgTemplate); + } +} diff --git a/lib/Model/RecentPage.php b/lib/Model/RecentPage.php new file mode 100644 index 000000000..4866aac51 --- /dev/null +++ b/lib/Model/RecentPage.php @@ -0,0 +1,56 @@ +collectiveName; + } + + public function setCollectiveName(string $collectiveName): self { + $this->collectiveName = $collectiveName; + return $this; + } + + public function getPageUrl(): string { + return $this->pageUrl; + } + + public function setPageUrl(string $pageUrl): self { + $this->pageUrl = $pageUrl; + return $this; + } + + public function getTitle(): string { + return $this->title; + } + + public function setTitle(string $title): self { + $this->title = $title; + return $this; + } + + public function getEmoji(): string { + return $this->emoji; + } + + public function setEmoji(string $emoji): self { + $this->emoji = $emoji; + return $this; + } + + public function getTimestamp(): int { + return $this->timestamp; + } + + public function setTimestamp(int $timestamp): self { + $this->timestamp = $timestamp; + return $this; + } +} diff --git a/lib/Service/CollectiveHelper.php b/lib/Service/CollectiveHelper.php index f245459b5..0d763da10 100644 --- a/lib/Service/CollectiveHelper.php +++ b/lib/Service/CollectiveHelper.php @@ -46,6 +46,7 @@ public function getCollectivesForUser(string $userId, bool $getLevel = true, boo return $circle->getSingleId(); }, $circles); $circles = array_combine($cids, $circles); + /** @var Collective[] $collectives */ $collectives = $this->collectiveMapper->findByCircleIds($cids); foreach ($collectives as $c) { $cid = $c->getCircleId(); @@ -59,7 +60,8 @@ public function getCollectivesForUser(string $userId, bool $getLevel = true, boo $userPageOrder = ($settings ? $settings->getSetting('page_order') : null) ?? Collective::defaultPageOrder; $userShowRecentPages = ($settings ? $settings->getSetting('show_recent_pages') : null) ?? Collective::defaultShowRecentPages; } - $collectiveInfos[] = new CollectiveInfo($c, + $collectiveInfos[] = new CollectiveInfo( + $c, $circle->getSanitizedName(), $level, null, @@ -88,7 +90,8 @@ public function getCollectivesTrashForUser(string $userId): array { $collectives = $this->collectiveMapper->findTrashByCircleIdsAndUser($cids, $userId); foreach ($collectives as $c) { $cid = $c->getCircleId(); - $collectiveInfos[] = new CollectiveInfo($c, + $collectiveInfos[] = new CollectiveInfo( + $c, $circles[$cid]->getSanitizedName(), $this->circleHelper->getLevel($cid, $userId) ); diff --git a/lib/Service/RecentPagesService.php b/lib/Service/RecentPagesService.php new file mode 100644 index 000000000..a60912df1 --- /dev/null +++ b/lib/Service/RecentPagesService.php @@ -0,0 +1,135 @@ +mimeTypeLoader = $mimeTypeLoader; + $this->config = $config; + $this->dbc = $dbc; + $this->urlGenerator = $urlGenerator; + $this->collectiveService = $collectiveService; + $this->l10n = $l10n; + } + + /** + * @return RecentPage[] + * @throws MissingDependencyException + * @throws Exception + */ + public function forUser(IUser $user, int $limit = 10): array { + try { + $collectives = $this->collectiveService->getCollectives($user->getUID()); + } catch (NotFoundException|NotPermittedException $e) { + return []; + } + + $qb = $this->dbc->getQueryBuilder(); + $appData = $this->getAppDataFolderName(); + $mimeTypeMd = $this->mimeTypeLoader->getId('text/markdown'); + + $expressions = []; + $collectivesMap = []; + foreach ($collectives as $collective) { + $value = sprintf($appData . '/collectives/%d/%%', $collective->getId()); + $expressions[] = $qb->expr()->like('f.path', $qb->createNamedParameter($value, IQueryBuilder::PARAM_STR)); + $collectivesMap[$collective->getId()] = $collective; + } + unset($collectives); + + $qb->select('p.*', 'f.mtime as timestamp', 'f.name as filename', 'f.path as path') + ->from('filecache', 'f') + ->leftJoin('f', 'collectives_pages', 'p', $qb->expr()->eq('f.fileid', 'p.file_id')) + ->where($qb->expr()->orX(...$expressions)) + ->andWhere($qb->expr()->eq('f.mimetype', $qb->createNamedParameter($mimeTypeMd, IQueryBuilder::PARAM_INT))) + ->orderBy('f.mtime', 'DESC') + ->setMaxResults($limit); + + $r = $qb->executeQuery(); + + $pages = []; + while ($row = $r->fetch()) { + $collectiveId = (int)explode('/', $row['path'], 4)[2]; + if (!isset($collectivesMap[$collectiveId])) { + continue; + } + + // cut out $appDataDir/collectives/%d/ prefix from front, and filename at the rear + $splitPath = explode('/', $row['path'], 4); + $internalPath = dirname(array_pop($splitPath)); + unset($splitPath); + + // prepare link and title + $pathParts = [$collectivesMap[$collectiveId]->getName()]; + if ($internalPath !== '' && $internalPath !== '.') { + $pathParts = array_merge($pathParts, explode('/', $internalPath)); + } + if ($row['filename'] !== 'Readme.md') { + $pathParts[] = basename($row['filename'], PageInfo::SUFFIX); + $title = basename($row['filename'], PageInfo::SUFFIX); + } elseif ($internalPath === '' || $internalPath === '.') { + $title = $this->l10n->t('Landing page'); + } else { + $title = basename($internalPath); + } + + $fileIdSuffix = '?fileId=' . $row['file_id']; + $url = $this->urlGenerator->linkToRoute('collectives.start.indexPath', ['path' => implode('/', $pathParts)]) . $fileIdSuffix; + + // build result model + // not returning a PageInfo instance because it would be either incomplete or too expensive to build completely + $recentPage = new RecentPage(); + $recentPage + ->setCollectiveName($this->collectiveService->getCollectiveNameWithEmoji($collectivesMap[$collectiveId])) + ->setTitle($title) + ->setPageUrl($url) + ->setTimestamp($row['timestamp']); + if ($row['emoji']) { + $recentPage->setEmoji($row['emoji']); + } + + $pages[] = $recentPage; + } + $r->closeCursor(); + + return $pages; + } + + private function getAppDataFolderName(): string { + $instanceId = $this->config->getSystemValueString('instanceid', ''); + if ($instanceId === '') { + throw new \RuntimeException('no instance id!'); + } + + return 'appdata_' . $instanceId; + } + +} diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 01f922f32..b761c04d7 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -1,9 +1,18 @@ - + + RecentPagesWidget SearchablePageReferenceProvider + + IAPIWidgetV2 + + + + + IReloadableWidget +