From c8098982039306ebce7ab1fe2aefa64acca74ad4 Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 2 Jun 2026 09:53:02 +0200 Subject: [PATCH 1/4] chore(playwright): use `list` reporter when run locally Spawning the browser to show HTML report after failed tests turned out to be rather annoying to me. Signed-off-by: Jonas --- playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index 39bfdcfd9..558f0c449 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/', From 878e55f97b371e5ff0929104104f911c10c30d4f Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 27 May 2026 19:23:27 +0200 Subject: [PATCH 2/4] feat: allow users to get notified about changes in a collective Sends notifications if pages got updated or deleted. Doesn't send notifications whne a page got created on purpose as usually pages get renamed straight after they got created. Fixes: #317 Signed-off-by: Jonas --- appinfo/routes.php | 2 + lib/AppInfo/Application.php | 3 + .../CollectiveUserSettingsController.php | 24 +++ lib/Db/Collective.php | 10 + lib/Db/CollectiveUserSettings.php | 9 + lib/Listeners/NodeWrittenListener.php | 15 +- lib/Notification/Notifier.php | 109 +++++++++++ lib/Service/CollectiveHelper.php | 1 + lib/Service/CollectiveUserSettingsService.php | 15 ++ lib/Service/NotificationService.php | 122 ++++++++++++ lib/Service/PageService.php | 24 +++ openapi.json | 184 ++++++++++++++++++ src/apis/collectives/userSettings.js | 19 +- .../Collective/NcActionCollectiveActions.vue | 29 +++ src/stores/collectives.js | 5 + tests/Unit/Service/CollectiveServiceTest.php | 1 + tests/Unit/Service/PageServiceTest.php | 4 + 17 files changed, 570 insertions(+), 6 deletions(-) create mode 100644 lib/Notification/Notifier.php create mode 100644 lib/Service/NotificationService.php diff --git a/appinfo/routes.php b/appinfo/routes.php index f4dd612cc..b5a6e47e8 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 bad9df1a2..fb91e1053 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 445d98e4c..7fa3a36f7 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 bool $subscribe Whether to receive notifications for changes + * + * @return DataResponse, array{}> + * @throws OCSNotFoundException Collective not found + * @throws OCSForbiddenException Not permitted + * + * 200: notify setting was set + */ + #[NoAdminRequired] + public function setNotify(int $collectiveId, bool $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 127ae97b6..a17b508d3 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 bool $userNotify = false; protected bool $canLeave = false; public function getCircleId(): string { @@ -233,6 +234,14 @@ public function setUserFavoritePages(array $userFavoritePages): void { $this->userFavoritePages = $userFavoritePages; } + public function getUserNotify(): bool { + return $this->userNotify; + } + + public function setUserNotify(bool $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 c1bbce1d5..141507763 100644 --- a/lib/Db/CollectiveUserSettings.php +++ b/lib/Db/CollectiveUserSettings.php @@ -30,6 +30,7 @@ class CollectiveUserSettings extends Entity implements JsonSerializable { 'show_members', 'show_recent_pages', 'favorite_pages', + 'notify', ]; protected ?int $collectiveId = null; @@ -106,6 +107,14 @@ public function setFavoritePages(array $favoritePages): void { $this->setSetting('favorite_pages', $favoritePages); } + /** + * @throws NotPermittedException + * @throws JsonException + */ + public function setNotify(bool $notify): void { + $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 4d9bf53af..9e1254e09 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/Notification/Notifier.php b/lib/Notification/Notifier.php new file mode 100644 index 000000000..b9902ec59 --- /dev/null +++ b/lib/Notification/Notifier.php @@ -0,0 +1,109 @@ +factory->get(Application::APP_NAME)->t('Collectives'); + } + + 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(); + } + + return $notification; + } +} diff --git a/lib/Service/CollectiveHelper.php b/lib/Service/CollectiveHelper.php index 8f72ae686..04a38f815 100644 --- a/lib/Service/CollectiveHelper.php +++ b/lib/Service/CollectiveHelper.php @@ -49,6 +49,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) ?? false); } } return $collectives; diff --git a/lib/Service/CollectiveUserSettingsService.php b/lib/Service/CollectiveUserSettingsService.php index 742688278..0cbf82196 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, bool $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 000000000..fe4722fa6 --- /dev/null +++ b/lib/Service/NotificationService.php @@ -0,0 +1,122 @@ +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') === true) { + $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 1ba37184b..92eeb062d 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 f31016d4b..8bc939f24 100644 --- a/openapi.json +++ b/openapi.json @@ -10294,6 +10294,190 @@ } } }, + "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/{collectiveId}/userSettings/subscribe": { + "put": { + "operationId": "collective_user_settings-set-subscribe", + "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": [ + "subscribe" + ], + "properties": { + "subscribe": { + "type": "boolean", + "description": "Whether to receive notifications for 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": "subscribe setting 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/src/apis/collectives/userSettings.js b/src/apis/collectives/userSettings.js index e0cbd5392..af4e5f911 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 6def4d469..5ad1f1c87 100644 --- a/src/components/Collective/NcActionCollectiveActions.vue +++ b/src/components/Collective/NcActionCollectiveActions.vue @@ -55,6 +55,17 @@ {{ t('collectives', 'Settings') }} + + + {{ notifyString }} + true, 'userShowRecentPages' => true, 'userFavoritePages' => [], + 'userNotify' => false, 'canLeave' => true, ], $collective->jsonSerialize()); } diff --git a/tests/Unit/Service/PageServiceTest.php b/tests/Unit/Service/PageServiceTest.php index 7cd6a9491..6b2533be2 100644 --- a/tests/Unit/Service/PageServiceTest.php +++ b/tests/Unit/Service/PageServiceTest.php @@ -22,6 +22,7 @@ use OCA\Collectives\Model\PageInfo; use OCA\Collectives\Service\CollectiveServiceBase; use OCA\Collectives\Service\NotFoundException; +use OCA\Collectives\Service\NotificationService; use OCA\Collectives\Service\NotPermittedException; use OCA\Collectives\Service\PageService; use OCA\Collectives\Service\SessionService; @@ -100,6 +101,8 @@ protected function setUp(): void { $pageLinkMapper = $this->createMock(PageLinkMapper::class); + $notificationService = $this->createMock(NotificationService::class); + $this->service = new PageService( $appManager, $this->pageMapper, @@ -112,6 +115,7 @@ protected function setUp(): void { $slugger, $tagMapper, $pageLinkMapper, + $notificationService, ); } From 636b0dc5b1e29b667ad3141de62880d7715526b9 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 28 May 2026 16:51:40 +0200 Subject: [PATCH 3/4] test(playwright): add tests for notifications feature Requires to install composer in Docker container as notifications app has run-time PHP dependencies. Signed-off-by: Jonas --- playwright/e2e/notifications.spec.ts | 68 ++++++++++++++++++++++ playwright/start-nextcloud-server.js | 16 ++++- playwright/support/fixtures/Collective.ts | 7 +++ playwright/support/fixtures/random-user.ts | 32 +++++----- 4 files changed, 109 insertions(+), 14 deletions(-) create mode 100644 playwright/e2e/notifications.spec.ts diff --git a/playwright/e2e/notifications.spec.ts b/playwright/e2e/notifications.spec.ts new file mode 100644 index 000000000..68356a007 --- /dev/null +++ b/playwright/e2e/notifications.spec.ts @@ -0,0 +1,68 @@ +/** + * 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 } from '@playwright/test' +import { test as createCollectiveTest } from '../support/fixtures/create-collectives.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' + +type MemberFixture = { page: Page, user: User } + +const test = createCollectiveTest.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) +} + +test.describe('Notifications', () => { + test.beforeEach(async ({ collective }) => { + await collective.notify() + }) + + test('User receives notification when member touches a page', async ({ collective, page, member }) => { + 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 collective.openCollective() + await expectNotification(page, collective.data.name, testPage.data.title) + }) + + test('User receives notification when member updates page content', async ({ collective, page, member }) => { + 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) + }) +}) diff --git a/playwright/start-nextcloud-server.js b/playwright/start-nextcloud-server.js index cb4f8530a..883b4075a 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-scripts --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 be9ef5154..48f6e66b0 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 notify(): Promise { + await this.page.request.put( + apiUrl('v1.0', 'collectives', this.data.id, 'userSettings', 'notify'), + { headers: ocsHeaders, data: { notify: true }, failOnStatusCode: true }, + ) + } } /** diff --git a/playwright/support/fixtures/random-user.ts b/playwright/support/fixtures/random-user.ts index 64f4ae69b..61f150e8f 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() }, From 4ca50498bf016020e71429a2a6b34a84e7296110 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 1 Jun 2026 17:03:20 +0200 Subject: [PATCH 4/4] feat(notifications): support "all changes", "notify only" and "off" Adds a submenu to NcActionsCollectivesActions for selecting the notification level, like it's done in Nextcloud Talk. Signed-off-by: Jonas --- .../CollectiveUserSettingsController.php | 6 +- lib/Db/Collective.php | 6 +- lib/Db/CollectiveUserSettings.php | 10 +- lib/Listeners/TextMentionListener.php | 13 + lib/Notification/Notifier.php | 12 + lib/Service/CollectiveHelper.php | 3 +- lib/Service/CollectiveUserSettingsService.php | 2 +- lib/Service/NotificationService.php | 3 +- openapi.json | 15 +- playwright/e2e/notifications.spec.ts | 69 ++++- playwright/start-nextcloud-server.js | 2 +- playwright/support/fixtures/Collective.ts | 4 +- .../support/sections/NavigationSection.ts | 4 +- .../Collective/NcActionCollectiveActions.vue | 60 ++++- src/components/Nav/CollectiveListItem.vue | 7 + src/components/Page/PageActionMenu.vue | 238 +++++++++--------- src/constants.js | 6 + tests/Unit/Service/CollectiveServiceTest.php | 2 +- 18 files changed, 301 insertions(+), 161 deletions(-) diff --git a/lib/Controller/CollectiveUserSettingsController.php b/lib/Controller/CollectiveUserSettingsController.php index 7fa3a36f7..17896fe7a 100644 --- a/lib/Controller/CollectiveUserSettingsController.php +++ b/lib/Controller/CollectiveUserSettingsController.php @@ -136,16 +136,16 @@ public function setFavoritePages(int $collectiveId, string $favoritePages): Data * Set whether the user wants notifications about changes in this collective * * @param int $collectiveId ID of the collective - * @param bool $subscribe Whether to receive notifications for changes + * @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 setting was set + * 200: notify level was set */ #[NoAdminRequired] - public function setNotify(int $collectiveId, bool $notify): DataResponse { + public function setNotify(int $collectiveId, int $notify): DataResponse { $this->handleErrorResponse(function () use ($collectiveId, $notify): void { $this->service->setNotify( $collectiveId, diff --git a/lib/Db/Collective.php b/lib/Db/Collective.php index a17b508d3..17df9dda1 100644 --- a/lib/Db/Collective.php +++ b/lib/Db/Collective.php @@ -78,7 +78,7 @@ class Collective extends Entity implements JsonSerializable { protected bool $userShowMembers = Collective::defaultShowMembers; protected bool $userShowRecentPages = Collective::defaultShowRecentPages; protected array $userFavoritePages = []; - protected bool $userNotify = false; + protected int $userNotify = CollectiveUserSettings::NOTIFY_MENTION; protected bool $canLeave = false; public function getCircleId(): string { @@ -234,11 +234,11 @@ public function setUserFavoritePages(array $userFavoritePages): void { $this->userFavoritePages = $userFavoritePages; } - public function getUserNotify(): bool { + public function getUserNotify(): int { return $this->userNotify; } - public function setUserNotify(bool $userNotify): void { + public function setUserNotify(int $userNotify): void { $this->userNotify = $userNotify; } diff --git a/lib/Db/CollectiveUserSettings.php b/lib/Db/CollectiveUserSettings.php index 141507763..4a36022f2 100644 --- a/lib/Db/CollectiveUserSettings.php +++ b/lib/Db/CollectiveUserSettings.php @@ -33,6 +33,11 @@ class CollectiveUserSettings extends Entity implements JsonSerializable { '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; @@ -111,7 +116,10 @@ public function setFavoritePages(array $favoritePages): void { * @throws NotPermittedException * @throws JsonException */ - public function setNotify(bool $notify): void { + 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); } diff --git a/lib/Listeners/TextMentionListener.php b/lib/Listeners/TextMentionListener.php index f29fa3df7..572878089 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 index b9902ec59..b095b96cc 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -36,6 +36,17 @@ public function getName(): string { return $this->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(); @@ -104,6 +115,7 @@ public function prepare(INotification $notification, string $languageCode): INot throw new UnknownNotificationException(); } + $this->setParsedSubjectFromRichSubject($notification); return $notification; } } diff --git a/lib/Service/CollectiveHelper.php b/lib/Service/CollectiveHelper.php index 04a38f815..43d037213 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,7 +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) ?? false); + $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 0cbf82196..ad43ec731 100644 --- a/lib/Service/CollectiveUserSettingsService.php +++ b/lib/Service/CollectiveUserSettingsService.php @@ -113,7 +113,7 @@ public function setFavoritePages(int $collectiveId, string $userId, string $favo * @throws NotFoundException * @throws NotPermittedException */ - public function setNotify(int $collectiveId, string $userId, bool $notify): void { + public function setNotify(int $collectiveId, string $userId, int $notify): void { $settings = $this->initSettings($collectiveId, $userId); $settings->setNotify($notify); diff --git a/lib/Service/NotificationService.php b/lib/Service/NotificationService.php index fe4722fa6..4f1c6722e 100644 --- a/lib/Service/NotificationService.php +++ b/lib/Service/NotificationService.php @@ -12,6 +12,7 @@ use OCA\Circles\Model\Member; use OCA\Collectives\AppInfo\Application; use OCA\Collectives\Db\Collective; +use OCA\Collectives\Db\CollectiveUserSettings; use OCA\Collectives\Db\CollectiveUserSettingsMapper; use OCA\Collectives\Model\PageInfo; use OCA\Collectives\Notification\Notifier; @@ -63,7 +64,7 @@ public function notifyMembers( $allSettings = $this->settingsMapper->findByCollectiveId($collective->getId()); $notifyUserIds = []; foreach ($allSettings as $setting) { - if ($setting->getSetting('notify') === true) { + if ($setting->getSetting('notify') === CollectiveUserSettings::NOTIFY_ALL) { $notifyUserIds[] = $setting->getUserId(); } } diff --git a/openapi.json b/openapi.json index 8bc939f24..b1f9b6a0d 100644 --- a/openapi.json +++ b/openapi.json @@ -10294,9 +10294,9 @@ } } }, - "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/{collectiveId}/userSettings/subscribe": { + "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/{collectiveId}/userSettings/notify": { "put": { - "operationId": "collective_user_settings-set-subscribe", + "operationId": "collective_user_settings-set-notify", "summary": "Set whether the user wants notifications about changes in this collective", "tags": [ "collective_user_settings" @@ -10316,12 +10316,13 @@ "schema": { "type": "object", "required": [ - "subscribe" + "notify" ], "properties": { - "subscribe": { - "type": "boolean", - "description": "Whether to receive notifications for changes" + "notify": { + "type": "integer", + "format": "int64", + "description": "Notification level (0=off, 1=mentions only, 2=all changes)" } } } @@ -10364,7 +10365,7 @@ ], "responses": { "200": { - "description": "subscribe setting was set", + "description": "notify level was set", "content": { "application/json": { "schema": { diff --git a/playwright/e2e/notifications.spec.ts b/playwright/e2e/notifications.spec.ts index 68356a007..8f192016b 100644 --- a/playwright/e2e/notifications.spec.ts +++ b/playwright/e2e/notifications.spec.ts @@ -6,16 +6,21 @@ import type { User as Account } from '@nextcloud/e2e-test-server' import type { Page } from '@playwright/test' -import { expect } 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 test = createCollectiveTest.extend<{ member: MemberFixture }>({ +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' }] }, @@ -38,23 +43,50 @@ async function expectNotification(page: Page, collectiveName: string, pageTitle: 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.beforeEach(async ({ collective }) => { - await collective.notify() + 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 receives notification when member touches a page', async ({ collective, page, member }) => { + 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.request.get( - apiUrl('v1.0', 'collectives', collective.data.id, 'pages', testPage.data.id, 'touch'), - { headers: ocsHeaders, failOnStatusCode: true }, - ) + + 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) + await expectNoNotification(page) }) - test('User receives notification when member updates page content', async ({ collective, page, member }) => { + 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', @@ -65,4 +97,19 @@ test.describe('Notifications', () => { 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 883b4075a..5440131e8 100644 --- a/playwright/start-nextcloud-server.js +++ b/playwright/start-nextcloud-server.js @@ -53,7 +53,7 @@ await runExec(['git', 'clone', '--depth=1', `--branch=${serverBranch}`, 'https:/ await runExec(['git', 'clone', '--depth=1', `--branch=${textBranch}`, 'https://github.com/nextcloud/text.git', 'apps/text'], { verbose: true }) // Install PHP dependencies for apps where required -await runExec(['sh', '-c', 'cd apps/notifications && composer install --no-dev --no-scripts --no-cache --no-interaction'], { verbose: true }) +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']) diff --git a/playwright/support/fixtures/Collective.ts b/playwright/support/fixtures/Collective.ts index 48f6e66b0..ac9ba0670 100644 --- a/playwright/support/fixtures/Collective.ts +++ b/playwright/support/fixtures/Collective.ts @@ -227,10 +227,10 @@ export class Collective { return account } - async notify(): Promise { + async setNotify(level: number): Promise { await this.page.request.put( apiUrl('v1.0', 'collectives', this.data.id, 'userSettings', 'notify'), - { headers: ocsHeaders, data: { notify: true }, failOnStatusCode: true }, + { headers: ocsHeaders, data: { notify: level }, failOnStatusCode: true }, ) } } diff --git a/playwright/support/sections/NavigationSection.ts b/playwright/support/sections/NavigationSection.ts index fb0802bd0..eca1df1b8 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/components/Collective/NcActionCollectiveActions.vue b/src/components/Collective/NcActionCollectiveActions.vue index 5ad1f1c87..aadae98c4 100644 --- a/src/components/Collective/NcActionCollectiveActions.vue +++ b/src/components/Collective/NcActionCollectiveActions.vue @@ -4,7 +4,7 @@ -->