diff --git a/appinfo/routes.php b/appinfo/routes.php index f4dd612cca..b5a6e47e8f 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -148,6 +148,8 @@ 'requirements' => ['apiVersion' => '(1.0)', 'collectiveId' => '\d+']], ['name' => 'collectiveUserSettings#setFavoritePages', 'url' => '/api/v{apiVersion}/collectives/{collectiveId}/userSettings/favoritePages', 'verb' => 'PUT', 'requirements' => ['apiVersion' => '(1.0)', 'collectiveId' => '\d+']], + ['name' => 'collectiveUserSettings#setNotify', 'url' => '/api/v{apiVersion}/collectives/{collectiveId}/userSettings/notify', 'verb' => 'PUT', + 'requirements' => ['apiVersion' => '(1.0)', 'collectiveId' => '\d+']], // Session API ['name' => 'session#create', 'url' => '/api/v{apiVersion}/collectives/{collectiveId}/sessions', 'verb' => 'POST', diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index bad9df1a23..fb91e10535 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -30,6 +30,7 @@ use OCA\Collectives\Middleware\PublicOCSMiddleware; use OCA\Collectives\Mount\CollectiveFolderManager; use OCA\Collectives\Mount\MountProvider; +use OCA\Collectives\Notification\Notifier; use OCA\Collectives\Reference\SearchablePageReferenceProvider; use OCA\Collectives\Search\CollectiveProvider; use OCA\Collectives\Search\PageContentProvider; @@ -82,6 +83,8 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(RenderReferenceEvent::class, CollectivesReferenceListener::class); $context->registerEventListener(MentionEvent::class, TextMentionListener::class); + $context->registerNotifierService(Notifier::class); + $context->registerMiddleware(PublicOCSMiddleware::class); $context->registerService(MountProvider::class, fn (ContainerInterface $c) => new MountProvider( diff --git a/lib/Controller/CollectiveUserSettingsController.php b/lib/Controller/CollectiveUserSettingsController.php index 445d98e4c9..17896fe7a5 100644 --- a/lib/Controller/CollectiveUserSettingsController.php +++ b/lib/Controller/CollectiveUserSettingsController.php @@ -131,4 +131,28 @@ public function setFavoritePages(int $collectiveId, string $favoritePages): Data }, $this->logger); return new DataResponse([]); } + + /** + * Set whether the user wants notifications about changes in this collective + * + * @param int $collectiveId ID of the collective + * @param int $notify Notification level (0=off, 1=mentions only, 2=all changes) + * + * @return DataResponse, array{}> + * @throws OCSNotFoundException Collective not found + * @throws OCSForbiddenException Not permitted + * + * 200: notify level was set + */ + #[NoAdminRequired] + public function setNotify(int $collectiveId, int $notify): DataResponse { + $this->handleErrorResponse(function () use ($collectiveId, $notify): void { + $this->service->setNotify( + $collectiveId, + $this->getUid(), + $notify + ); + }, $this->logger); + return new DataResponse([]); + } } diff --git a/lib/Db/Collective.php b/lib/Db/Collective.php index 127ae97b62..17df9dda18 100644 --- a/lib/Db/Collective.php +++ b/lib/Db/Collective.php @@ -78,6 +78,7 @@ class Collective extends Entity implements JsonSerializable { protected bool $userShowMembers = Collective::defaultShowMembers; protected bool $userShowRecentPages = Collective::defaultShowRecentPages; protected array $userFavoritePages = []; + protected int $userNotify = CollectiveUserSettings::NOTIFY_MENTION; protected bool $canLeave = false; public function getCircleId(): string { @@ -233,6 +234,14 @@ public function setUserFavoritePages(array $userFavoritePages): void { $this->userFavoritePages = $userFavoritePages; } + public function getUserNotify(): int { + return $this->userNotify; + } + + public function setUserNotify(int $userNotify): void { + $this->userNotify = $userNotify; + } + public function getUserPermissions(bool $isShare = false): int { // Public shares always get permissions of a simple member plus sharing permission of owner if ($isShare) { @@ -309,6 +318,7 @@ public function jsonSerialize(): array { 'userShowMembers' => $this->userShowMembers, 'userShowRecentPages' => $this->userShowRecentPages, 'userFavoritePages' => $this->userFavoritePages, + 'userNotify' => $this->userNotify, 'canLeave' => $this->getCanLeave(), ]; } diff --git a/lib/Db/CollectiveUserSettings.php b/lib/Db/CollectiveUserSettings.php index c1bbce1d5c..4a36022f2a 100644 --- a/lib/Db/CollectiveUserSettings.php +++ b/lib/Db/CollectiveUserSettings.php @@ -30,8 +30,14 @@ class CollectiveUserSettings extends Entity implements JsonSerializable { 'show_members', 'show_recent_pages', 'favorite_pages', + 'notify', ]; + public const NOTIFY_OFF = 0; + public const NOTIFY_MENTION = 1; + public const NOTIFY_ALL = 2; + private const NOTIFY_LEVELS = [self::NOTIFY_OFF, self::NOTIFY_MENTION, self::NOTIFY_ALL]; + protected ?int $collectiveId = null; protected ?string $userId = null; protected int $pageOrder = Collective::defaultPageOrder; @@ -106,6 +112,17 @@ public function setFavoritePages(array $favoritePages): void { $this->setSetting('favorite_pages', $favoritePages); } + /** + * @throws NotPermittedException + * @throws JsonException + */ + public function setNotify(int $notify): void { + if (!in_array($notify, self::NOTIFY_LEVELS, true)) { + throw new NotPermittedException('Invalid notify value: ' . $notify); + } + $this->setSetting('notify', $notify); + } + public function jsonSerialize(): array { return [ 'id' => $this->id, diff --git a/lib/Listeners/NodeWrittenListener.php b/lib/Listeners/NodeWrittenListener.php index 4d9bf53af1..9e1254e092 100644 --- a/lib/Listeners/NodeWrittenListener.php +++ b/lib/Listeners/NodeWrittenListener.php @@ -13,18 +13,22 @@ use OCA\Collectives\Db\PageLinkMapper; use OCA\Collectives\Fs\MarkdownHelper; use OCA\Collectives\Mount\CollectiveStorage; +use OCA\Collectives\Service\PageService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Files\Events\Node\NodeWrittenEvent; use OCP\Files\File; use OCP\IConfig; +use OCP\IUserSession; /** @template-implements IEventListener */ class NodeWrittenListener implements IEventListener { public function __construct( - private IConfig $config, - private PageLinkMapper $pageLinkMapper, - private CollectiveMapper $collectiveMapper, + private readonly IConfig $config, + private readonly PageLinkMapper $pageLinkMapper, + private readonly CollectiveMapper $collectiveMapper, + private readonly IUserSession $userSession, + private readonly PageService $pageService, ) { } @@ -46,5 +50,10 @@ public function handle(Event $event): void { $linkedPageIds = MarkdownHelper::getLinkedPageIds($collective, $node->getContent(), $this->config->getSystemValue('trusted_domains', [])); $this->pageLinkMapper->updateByPageId($node->getId(), $linkedPageIds); + + $userId = $this->userSession->getUser()?->getUID(); + if ($userId) { + $this->pageService->notifyContentChange($node, $collective, $userId); + } } } diff --git a/lib/Listeners/TextMentionListener.php b/lib/Listeners/TextMentionListener.php index f29fa3df7a..5728780893 100644 --- a/lib/Listeners/TextMentionListener.php +++ b/lib/Listeners/TextMentionListener.php @@ -9,6 +9,8 @@ namespace OCA\Collectives\Listeners; +use OCA\Collectives\Db\CollectiveUserSettings; +use OCA\Collectives\Db\CollectiveUserSettingsMapper; use OCA\Collectives\Mount\CollectiveMountPoint; use OCA\Collectives\Service\CollectiveService; use OCA\Collectives\Service\PageService; @@ -17,6 +19,7 @@ use OCP\EventDispatcher\IEventListener; use OCP\IL10N; use OCP\IURLGenerator; +use OCP\Notification\AlreadyProcessedException; /** @template-implements IEventListener */ class TextMentionListener implements IEventListener { @@ -25,6 +28,7 @@ public function __construct( private IURLGenerator $urlGenerator, private CollectiveService $collectiveService, private PageService $pageService, + private CollectiveUserSettingsMapper $settingsMapper, private ?string $userId, ) { } @@ -44,6 +48,15 @@ public function handle(Event $event): void { } $collective = $this->collectiveService->getCollective($mountPoint->getFolderId(), $this->userId); + + // Skip notification if mentioned user has notifications turned off + $mentionedUserId = $event->getNotification()->getUser(); + $settings = $this->settingsMapper->findByCollectiveAndUser($collective->getId(), $mentionedUserId); + $notifyLevel = ($settings?->getSetting('notify')) ?? CollectiveUserSettings::NOTIFY_MENTION; + if ($notifyLevel < CollectiveUserSettings::NOTIFY_MENTION) { + throw new AlreadyProcessedException(); + } + $pageInfo = $this->pageService->findByFile($mountPoint->getFolderId(), $event->getFile(), $this->userId); $collectiveLink = $this->urlGenerator->linkToRouteAbsolute('collectives.start.index') . rawurlencode($collective->getName()); diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php new file mode 100644 index 0000000000..b095b96cc1 --- /dev/null +++ b/lib/Notification/Notifier.php @@ -0,0 +1,121 @@ +factory->get(Application::APP_NAME)->t('Collectives'); + } + + private function setParsedSubjectFromRichSubject(INotification $notification): void { + $placeholders = $replacements = []; + foreach ($notification->getRichSubjectParameters() as $key => $value) { + $placeholders[] = '{' . $key . '}'; + $replacements[] = $value['name'] ?? ''; + } + $notification->setParsedSubject( + str_replace($placeholders, $replacements, $notification->getParsedSubject()) + ); + } + + public function prepare(INotification $notification, string $languageCode): INotification { + if ($notification->getApp() !== Application::APP_NAME) { + throw new UnknownNotificationException(); + } + + $l = $this->factory->get(Application::APP_NAME, $languageCode); + $params = $notification->getSubjectParameters(); + + $actingUser = $params['actingUser']; + $actingDisplayName = $this->userManager->getDisplayName($actingUser) ?? $actingUser; + + $collectiveRichObject = [ + 'type' => 'highlight', + 'id' => $params['collectiveId'], + 'name' => $params['collectiveName'], + 'link' => $params['collectiveLink'], + ]; + + $pageRichObject = [ + 'type' => 'highlight', + 'id' => $params['pageId'], + 'name' => $params['pageTitle'], + ]; + if (!empty($params['pageLink'])) { + $pageRichObject['link'] = $params['pageLink']; + } + + $userRichObject = [ + 'type' => 'user', + 'id' => $actingUser, + 'name' => $actingDisplayName, + ]; + + $richParams = [ + 'user' => $userRichObject, + 'collective' => $collectiveRichObject, + 'page' => $pageRichObject, + ]; + + $notification->setIcon( + $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->imagePath('collectives', 'collectives-dark.svg') + ) + ); + + if (!empty($params['pageLink'])) { + $notification->setLink($params['pageLink']); + } else { + $notification->setLink($params['collectiveLink']); + } + + switch ($notification->getSubject()) { + case self::SUBJECT_PAGE_UPDATED: + $notification->setRichSubject( + $l->t('{user} updated {page} in {collective}'), + $richParams, + ); + break; + case self::SUBJECT_PAGE_DELETED: + $notification->setRichSubject( + $l->t('{user} deleted {page} from {collective}'), + $richParams, + ); + break; + default: + throw new UnknownNotificationException(); + } + + $this->setParsedSubjectFromRichSubject($notification); + return $notification; + } +} diff --git a/lib/Service/CollectiveHelper.php b/lib/Service/CollectiveHelper.php index 8f72ae6863..43d0372133 100644 --- a/lib/Service/CollectiveHelper.php +++ b/lib/Service/CollectiveHelper.php @@ -11,6 +11,7 @@ use OCA\Collectives\Db\Collective; use OCA\Collectives\Db\CollectiveMapper; +use OCA\Collectives\Db\CollectiveUserSettings; use OCA\Collectives\Db\CollectiveUserSettingsMapper; class CollectiveHelper { @@ -49,6 +50,7 @@ public function getCollectivesForUser(string $userId, bool $getLevel = true, boo $c->setUserShowMembers(($settings ? $settings->getSetting('show_members') : null) ?? Collective::defaultShowMembers); $c->setUserShowRecentPages(($settings ? $settings->getSetting('show_recent_pages') : null) ?? Collective::defaultShowRecentPages); $c->setUserFavoritePages(($settings ? $settings->getSetting('favorite_pages') : null) ?? []); + $c->setUserNotify(($settings ? $settings->getSetting('notify') : null) ?? CollectiveUserSettings::NOTIFY_MENTION); } } return $collectives; diff --git a/lib/Service/CollectiveUserSettingsService.php b/lib/Service/CollectiveUserSettingsService.php index 7426882789..ad43ec7318 100644 --- a/lib/Service/CollectiveUserSettingsService.php +++ b/lib/Service/CollectiveUserSettingsService.php @@ -108,4 +108,19 @@ public function setFavoritePages(int $collectiveId, string $userId, string $favo throw new NotPermittedException($e->getMessage(), 0, $e); } } + + /** + * @throws NotFoundException + * @throws NotPermittedException + */ + public function setNotify(int $collectiveId, string $userId, int $notify): void { + $settings = $this->initSettings($collectiveId, $userId); + $settings->setNotify($notify); + + try { + $this->collectiveUserSettingsMapper->insertOrUpdate($settings); + } catch (Exception $e) { + throw new NotPermittedException($e->getMessage(), 0, $e); + } + } } diff --git a/lib/Service/NotificationService.php b/lib/Service/NotificationService.php new file mode 100644 index 0000000000..4f1c6722e6 --- /dev/null +++ b/lib/Service/NotificationService.php @@ -0,0 +1,123 @@ +circleHelper->getCircle($collective->getCircleUniqueId(), null, true); + } catch (MissingDependencyException|NotFoundException|NotPermittedException $e) { + $this->logger->warning('Could not fetch circle members for notification: ' . $e->getMessage(), ['exception' => $e]); + return; + } + + // Collect direct user member IDs + $memberUserIds = []; + foreach ($circle->getMembers() as $member) { + // TODO: also notify indirect circle/team members + if ($member->getUserType() === Member::TYPE_USER) { + $memberUserIds[] = $member->getUserId(); + } + } + + if (empty($memberUserIds)) { + return; + } + + // Fetch all user settings for this collective in one query + $allSettings = $this->settingsMapper->findByCollectiveId($collective->getId()); + $notifyUserIds = []; + foreach ($allSettings as $setting) { + if ($setting->getSetting('notify') === CollectiveUserSettings::NOTIFY_ALL) { + $notifyUserIds[] = $setting->getUserId(); + } + } + + // Build URLs + $baseUrl = $this->urlGenerator->linkToRouteAbsolute('collectives.start.index'); + $collectiveLink = $baseUrl . rawurlencode($collective->getUrlPath()); + $pageLink = $subject !== Notifier::SUBJECT_PAGE_DELETED + ? $baseUrl . $pageRelativePath + : ''; + + $collectiveNameWithEmoji = $collective->getEmoji() + ? $collective->getEmoji() . ' ' . $collective->getName() + : $collective->getName(); + + $pageTitleWithEmoji = $pageInfo->getEmoji() + ? $pageInfo->getEmoji() . ' ' . $pageInfo->getTitle() + : $pageInfo->getTitle(); + + $subjectParams = [ + 'actingUser' => $actingUserId, + 'collectiveId' => (string)$collective->getId(), + 'collectiveName' => $collectiveNameWithEmoji, + 'collectiveLink' => $collectiveLink, + 'pageId' => (string)$pageInfo->getId(), + 'pageTitle' => $pageTitleWithEmoji, + 'pageLink' => $pageLink, + ]; + + foreach ($memberUserIds as $userId) { + // Skip acting and non-notify users + if ($userId === $actingUserId) { + continue; + } + if (!in_array($userId, $notifyUserIds, true)) { + continue; + } + + // Replace existing notifications for this page + $filter = $this->notificationManager->createNotification(); + $filter->setApp(Application::APP_NAME) + ->setUser($userId) + ->setObject('page', (string)$pageInfo->getId()); + $this->notificationManager->markProcessed($filter); + + $notification = $this->notificationManager->createNotification(); + $notification->setApp(Application::APP_NAME) + ->setUser($userId) + ->setDateTime(new \DateTime()) + ->setObject('page', (string)$pageInfo->getId()) + ->setSubject($subject, $subjectParams); + + $this->notificationManager->notify($notification); + } + } +} diff --git a/lib/Service/PageService.php b/lib/Service/PageService.php index 1ba37184b6..92eeb062de 100644 --- a/lib/Service/PageService.php +++ b/lib/Service/PageService.php @@ -18,6 +18,7 @@ use OCA\Collectives\Fs\NodeHelper; use OCA\Collectives\Fs\UserFolderHelper; use OCA\Collectives\Model\PageInfo; +use OCA\Collectives\Notification\Notifier; use OCA\Collectives\Trash\PageTrashBackend; use OCA\NotifyPush\Queue\IQueue; use OCP\App\IAppManager; @@ -53,6 +54,7 @@ public function __construct( private readonly SluggerInterface $slugger, private readonly TagMapper $tagMapper, private readonly PageLinkMapper $pageLinkMapper, + private readonly NotificationService $notificationService, ) { try { $this->pushQueue = $container->get(IQueue::class); @@ -783,6 +785,8 @@ public function touch(int $collectiveId, int $id, string $userId): PageInfo { $pageInfo->setLastUserDisplayName($this->userManager->getDisplayName($userId)); $this->updatePage($collectiveId, $pageInfo->getId(), $userId); $this->notifyPush(['collectiveId' => $collectiveId, 'pages' => [$pageInfo]]); + $collective = $this->getCollective($collectiveId, $userId); + $this->notificationService->notifyMembers($collective, $pageInfo, Notifier::SUBJECT_PAGE_UPDATED, $userId, $this->getPageLink($collective->getUrlPath(), $pageInfo)); return $pageInfo; } @@ -1001,6 +1005,9 @@ public function move(int $collectiveId, int $id, ?int $parentId, ?string $title, $this->notifyPush(['collectiveId' => $collectiveId, 'pages' => [$newPageInfo]]); } + $collective = $this->getCollective($collectiveId, $userId); + $this->notificationService->notifyMembers($collective, $newPageInfo, Notifier::SUBJECT_PAGE_UPDATED, $userId, $this->getPageLink($collective->getUrlPath(), $newPageInfo)); + return $newPageInfo; } @@ -1095,6 +1102,8 @@ public function setEmoji(int $collectiveId, int $id, ?string $emoji, string $use $pageInfo->setEmoji($emoji); $this->updatePage($collectiveId, $pageInfo->getId(), $userId, $emoji); $this->notifyPush(['collectiveId' => $collectiveId, 'pages' => [$pageInfo]]); + $collective = $this->getCollective($collectiveId, $userId); + $this->notificationService->notifyMembers($collective, $pageInfo, Notifier::SUBJECT_PAGE_UPDATED, $userId, $this->getPageLink($collective->getUrlPath(), $pageInfo)); return $pageInfo; } @@ -1244,6 +1253,7 @@ public function trash(int $collectiveId, int $id, string $userId, bool $direct = throw new NotPermittedException($e->getMessage(), 0, $e); } + $collective = $this->getCollective($collectiveId, $userId); $this->initTrashBackend(); if ($direct || !$this->trashBackend) { // Delete directly if desired or trash is not available @@ -1251,6 +1261,7 @@ public function trash(int $collectiveId, int $id, string $userId, bool $direct = $this->pageMapper->deleteByFileId($id); $oldParentPageInfo = $this->removeFromSubpageOrder($collectiveId, $parentId, $id, $userId); $this->notifyPush(['collectiveId' => $collectiveId, 'pages' => [$oldParentPageInfo], 'removed' => [$id]]); + $this->notificationService->notifyMembers($collective, $pageInfo, Notifier::SUBJECT_PAGE_DELETED, $userId, $this->getPageLink($collective->getUrlPath(), $pageInfo)); return $pageInfo; } @@ -1261,6 +1272,7 @@ public function trash(int $collectiveId, int $id, string $userId, bool $direct = $pageInfo->setTrashTimestamp($trashedPage->getTrashTimestamp()); $this->notifyPush(['collectiveId' => $collectiveId, 'pages' => [$pageInfo]]); + $this->notificationService->notifyMembers($collective, $pageInfo, Notifier::SUBJECT_PAGE_DELETED, $userId, $this->getPageLink($collective->getUrlPath(), $pageInfo)); return $pageInfo; } @@ -1338,4 +1350,16 @@ public function getPageLink(string $collectiveUrlPath, PageInfo $pageInfo, bool $pageTitleRoute ])) . $fileIdQuery; } + + public function notifyContentChange(File $file, Collective $collective, string $userId): void { + try { + $pageInfo = $this->getPageByFile($file); + } catch (\Throwable) { + return; + } + $this->notificationService->notifyMembers( + $collective, $pageInfo, Notifier::SUBJECT_PAGE_UPDATED, + $userId, $this->getPageLink($collective->getUrlPath(), $pageInfo) + ); + } } diff --git a/openapi.json b/openapi.json index f31016d4b1..b1f9b6a0dc 100644 --- a/openapi.json +++ b/openapi.json @@ -10294,6 +10294,191 @@ } } }, + "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/{collectiveId}/userSettings/notify": { + "put": { + "operationId": "collective_user_settings-set-notify", + "summary": "Set whether the user wants notifications about changes in this collective", + "tags": [ + "collective_user_settings" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "notify" + ], + "properties": { + "notify": { + "type": "integer", + "format": "int64", + "description": "Notification level (0=off, 1=mentions only, 2=all changes)" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1.0" + ], + "default": "1.0" + } + }, + { + "name": "collectiveId", + "in": "path", + "description": "ID of the collective", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "notify level was set", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Collective not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Not permitted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/{collectiveId}/sessions": { "post": { "operationId": "session-create", diff --git a/playwright.config.ts b/playwright.config.ts index 39bfdcfd9e..558f0c449a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ // dot (so we have a quick overview in the logs while the tests are running) // github (to have annotations in the PR) // locally we just want the html report with the traces - reporter: process.env.CI ? [['blob'], ['line'], ['github']] : 'html', + reporter: process.env.CI ? [['blob'], ['line'], ['github']] : 'list', use: { // Base URL to use in actions like `await page.goto('./')`. baseURL: process.env.baseURL ?? 'http://localhost:8089/index.php/', diff --git a/playwright/e2e/notifications.spec.ts b/playwright/e2e/notifications.spec.ts new file mode 100644 index 0000000000..8f192016ba --- /dev/null +++ b/playwright/e2e/notifications.spec.ts @@ -0,0 +1,115 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User as Account } from '@nextcloud/e2e-test-server' +import type { Page } from '@playwright/test' + +import { expect, mergeTests } from '@playwright/test' +import { notifyLevels } from '../../src/constants.js' +import { test as createCollectiveTest } from '../support/fixtures/create-collectives.ts' +import { test as navigationTest } from '../support/fixtures/navigation.ts' +import { loginAsUser } from '../support/fixtures/random-user.ts' +import { User } from '../support/fixtures/User.ts' +import { randomString } from '../support/helpers/randomString.ts' +import { apiUrl, ocsHeaders } from '../support/helpers/urls.ts' +import { EditorSection } from '../support/sections/EditorSection.ts' + +type MemberFixture = { page: Page, user: User } + +const mergedTest = mergeTests(createCollectiveTest, navigationTest) + +const test = mergedTest.extend<{ member: MemberFixture }>({ + // eslint-disable-next-line no-empty-pattern + collectiveConfigs: async ({}, use) => use([ + { name: randomString(), pages: [{ title: 'Notified Page' }] }, + ]), + member: async ({ collective, browser, baseURL }, use) => { + const account: Account = await collective.addMember() + const memberPage = await loginAsUser(browser, baseURL, account) + await use({ page: memberPage, user: new User(account) }) + await memberPage.close() + }, +}) + +async function expectNotification(page: Page, collectiveName: string, pageTitle: string): Promise { + await page.locator('.notifications-button').click() + const notificationBox = page.locator('#header-menu-notifications') + await expect(notificationBox).toBeVisible() + const notification = notificationBox.locator('.notification').first() + await expect(notification).toBeVisible() + await expect(notification).toContainText(collectiveName) + await expect(notification).toContainText(pageTitle) +} + +async function expectNoNotification(page: Page): Promise { + await page.locator('.notifications-button').click() + const notificationBox = page.locator('#header-menu-notifications') + await expect(notificationBox).toBeVisible() + const notification = notificationBox.locator('.notification') + await expect(notification).toHaveCount(0) +} + +test.describe('Notifications', () => { + test('User receives notification on being mentioned by default', async ({ collective, page, member, user }) => { + const testPage = collective.getPageByTitle('Notified Page') + + await member.page.goto(testPage.getPageUrl()) + const memberEditor = new EditorSection(member.page) + await memberEditor.switchMode(true) + await memberEditor.getContent().pressSequentially(`Mentioning @${user.account.userId}`) + const suggestion = memberEditor.getMentionSuggestions().getByText(user.account.userId, { exact: true }) + await suggestion.click() + await memberEditor.save() + + await collective.openCollective() + await expectNotification(page, collective.data.name, testPage.data.title) + }) + + test('User does not receive notification when notify level is off', async ({ collective, page, member, user }) => { + await collective.setNotify(notifyLevels.NOTIFY_OFF) + + const testPage = collective.getPageByTitle('Notified Page') + + await member.page.goto(testPage.getPageUrl()) + const memberEditor = new EditorSection(member.page) + await memberEditor.switchMode(true) + await memberEditor.getContent().pressSequentially(`Mentioning @${user.account.userId}`) + const suggestion = memberEditor.getMentionSuggestions().getByText(user.account.userId, { exact: true }) + await suggestion.click() + await memberEditor.save() + + await collective.openCollective() + await expectNoNotification(page) + }) + + test('User receives notification when activated and member updates page content', async ({ collective, page, member }) => { + await collective.setNotify(notifyLevels.NOTIFY_ALL) + + const testPage = collective.getPageByTitle('Notified Page') + await testPage.setContent({ + content: '# Updated by member', + user: member.user, + page: member.page, + }) + + await collective.openCollective() + await expectNotification(page, collective.data.name, testPage.data.title) + }) + + test('User sets notify via UI and receives notification when member touches a page', async ({ collective, page, member, navigation }) => { + await collective.openCollective() + await navigation.open() + await navigation.clickCollectiveMenu(collective.data.name, 'Notifications') + await page.getByRole('button', { name: 'All changes', exact: true }).click() + + const testPage = collective.getPageByTitle('Notified Page') + await member.page.request.get( + apiUrl('v1.0', 'collectives', collective.data.id, 'pages', testPage.data.id, 'touch'), + { headers: ocsHeaders, failOnStatusCode: true }, + ) + + await expectNotification(page, collective.data.name, testPage.data.title) + }) +}) diff --git a/playwright/start-nextcloud-server.js b/playwright/start-nextcloud-server.js index cb4f8530aa..5440131e86 100644 --- a/playwright/start-nextcloud-server.js +++ b/playwright/start-nextcloud-server.js @@ -38,11 +38,25 @@ process.on('SIGINT', stop) // Start the Nextcloud docker container const ip = await start() await waitOnNextcloud(ip) + +// Install PHP composer +await runExec( + ['sh', '-c', 'curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer'], + { user: 'root', verbose: true }, +) + +// Download required apps await runExec(['git', 'clone', '--depth=1', `--branch=${serverBranch}`, 'https://github.com/nextcloud/circles.git', 'apps/circles'], { verbose: true }) await runExec(['git', 'clone', '--depth=1', `--branch=${serverBranch}`, 'https://github.com/nextcloud/files_pdfviewer.git', 'apps/files_pdfviewer'], { verbose: true }) +await runExec(['git', 'clone', '--depth=1', `--branch=${serverBranch}`, 'https://github.com/nextcloud/notifications.git', 'apps/notifications'], { verbose: true }) await runExec(['git', 'clone', '--depth=1', `--branch=${serverBranch}`, 'https://github.com/nextcloud/password_policy.git', 'apps/password_policy'], { verbose: true }) await runExec(['git', 'clone', '--depth=1', `--branch=${textBranch}`, 'https://github.com/nextcloud/text.git', 'apps/text'], { verbose: true }) -await configureNextcloud(['collectives', 'circles', 'files_pdfviewer', 'files_lock', 'text', 'viewer']) + +// Install PHP dependencies for apps where required +await runExec(['sh', '-c', 'cd apps/notifications && composer install --no-dev --no-cache --no-interaction'], { verbose: true }) + +// Configure Nextcloud +await configureNextcloud(['collectives', 'circles', 'files_pdfviewer', 'files_lock', 'notifications', 'text', 'viewer']) // Idle to wait for shutdown while (true) { diff --git a/playwright/support/fixtures/Collective.ts b/playwright/support/fixtures/Collective.ts index be9ef51546..ac9ba0670e 100644 --- a/playwright/support/fixtures/Collective.ts +++ b/playwright/support/fixtures/Collective.ts @@ -226,6 +226,13 @@ export class Collective { this.extraMembers.push(account) return account } + + async setNotify(level: number): Promise { + await this.page.request.put( + apiUrl('v1.0', 'collectives', this.data.id, 'userSettings', 'notify'), + { headers: ocsHeaders, data: { notify: level }, failOnStatusCode: true }, + ) + } } /** diff --git a/playwright/support/fixtures/random-user.ts b/playwright/support/fixtures/random-user.ts index 64f4ae69b5..61f150e8f2 100644 --- a/playwright/support/fixtures/random-user.ts +++ b/playwright/support/fixtures/random-user.ts @@ -4,6 +4,7 @@ */ import type { User as Account } from '@nextcloud/e2e-test-server' +import type { Browser, Page } from '@playwright/test' import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' import { test as base } from '@playwright/test' @@ -14,6 +15,23 @@ export interface UserFixture { user: User } +/** + * Log in as any account in a new browser page and return it. + * + * @param browser - the browser object + * @param baseURL - the base URL + * @param account - the user account + */ +export async function loginAsUser(browser: Browser, baseURL: string | undefined, account: Account): Promise { + // Important: make sure we authenticate in a clean environment by unsetting storage state. + const page = await browser.newPage({ storageState: undefined, baseURL }) + await login(page.request, account) + const tokenResponse = await page.request.get('./csrftoken', { failOnStatusCode: true }) + const { token } = (await tokenResponse.json()) as { token: string } + await page.context().setExtraHTTPHeaders({ requesttoken: token }) + return page +} + /** * This test fixture ensures a new random user is created and used for the test (current page) */ @@ -24,19 +42,7 @@ export const test = base.extend({ await use(account) }, { scope: 'worker' }], page: async ({ account, browser, baseURL }, use) => { - // Important: make sure we authenticate in a clean environment by unsetting storage state. - const page = await browser.newPage({ - storageState: undefined, - baseURL, - }) - - await login(page.request, account) - const tokenResponse = await page.request.get('./csrftoken', { - failOnStatusCode: true, - }) - const { token } = (await tokenResponse.json()) as { token: string } - await page.context().setExtraHTTPHeaders({ requesttoken: token }) - + const page = await loginAsUser(browser, baseURL, account) await use(page) await page.close() }, diff --git a/playwright/support/sections/NavigationSection.ts b/playwright/support/sections/NavigationSection.ts index fb0802bd09..eca1df1b85 100644 --- a/playwright/support/sections/NavigationSection.ts +++ b/playwright/support/sections/NavigationSection.ts @@ -45,7 +45,9 @@ export class NavigationSection { .hover() await collectiveItem.getByRole('button', { name: 'Actions' }) .click() - await this.page.getByRole('button', { name: action, exact: true }) + // The extra `.action-item__popover` locator is needed to not conflict with other button with same name in DOM + await this.page.locator('.action-item__popper:visible') + .getByRole('button', { name: action, exact: true }) .click() } diff --git a/src/apis/collectives/userSettings.js b/src/apis/collectives/userSettings.js index e0cbd53920..af4e5f9116 100644 --- a/src/apis/collectives/userSettings.js +++ b/src/apis/collectives/userSettings.js @@ -37,7 +37,7 @@ export function setCollectiveUserSettingPageOrder(collectiveId, pageOrder) { */ export function setCollectiveUserSettingShowMembers(collectiveId, showMembers) { return axios.put( - collectiveUserSettingsApiUrl(collectiveId, 'showMembers'), + collectiveUserSettingsApiUrl(collectiveId, ['showMembers']), { showMembers }, ) } @@ -50,7 +50,7 @@ export function setCollectiveUserSettingShowMembers(collectiveId, showMembers) { */ export function setCollectiveUserSettingShowRecentPages(collectiveId, showRecentPages) { return axios.put( - collectiveUserSettingsApiUrl(collectiveId, 'showRecentPages'), + collectiveUserSettingsApiUrl(collectiveId, ['showRecentPages']), { showRecentPages }, ) } @@ -63,7 +63,20 @@ export function setCollectiveUserSettingShowRecentPages(collectiveId, showRecent */ export function setCollectiveUserSettingFavoritePages(collectiveId, favoritePages) { return axios.put( - collectiveUserSettingsApiUrl(collectiveId, 'favoritePages'), + collectiveUserSettingsApiUrl(collectiveId, ['favoritePages']), { favoritePages: JSON.stringify(favoritePages) }, ) } + +/** + * Set whether user gets notified about changes in the collective + * + * @param {number} collectiveId ID of the collective to be updated + * @param {boolean} notify the desired value + */ +export function setCollectiveUserSettingNotify(collectiveId, notify) { + return axios.put( + collectiveUserSettingsApiUrl(collectiveId, ['notify']), + { notify }, + ) +} diff --git a/src/components/Collective/NcActionCollectiveActions.vue b/src/components/Collective/NcActionCollectiveActions.vue index 6def4d4697..aadae98c4a 100644 --- a/src/components/Collective/NcActionCollectiveActions.vue +++ b/src/components/Collective/NcActionCollectiveActions.vue @@ -4,7 +4,7 @@ -->