Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion lib/Controller/SessionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OCA\Text\Service\NotificationService;
use OCA\Text\Service\SessionService;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http\Attribute\AnonRateLimit;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
Expand Down Expand Up @@ -95,12 +96,18 @@ public function save(int $version, string $autosaveContent, string $documentStat
#[PublicPage]
#[RequireDocumentSession]
#[UserRateLimit(limit: 5, period: 120)]
#[AnonRateLimit(limit: 5, period: 120)]
public function mention(string $mention): DataResponse {
if ($this->getSession()->isGuest() && !$this->sessionService->isUserInDocument($this->getDocument()->getId(), $mention)) {
return new DataResponse([], 403);
}

return new DataResponse($this->notificationService->mention($this->getDocument()->getId(), $mention));
$session = $this->getSession();
return new DataResponse($this->notificationService->mention(
$this->getDocument()->getId(),
$mention,
$session->isGuest() ? ($session->getGuestName() ?? '') : null,
));
}

private function loginSessionUser(): void {
Expand Down
38 changes: 27 additions & 11 deletions lib/Notification/Notifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
class Notifier implements INotifier {
public const TYPE_MENTIONED = 'mentioned';
public const SUBJECT_MENTIONED_SOURCE_USER = 'sourceUser';
public const SUBJECT_MENTIONED_SOURCE_GUEST = 'sourceGuest';
public const SUBJECT_MENTIONED_TARGET_USER = 'targetUser';

public function __construct(
Expand Down Expand Up @@ -55,15 +56,34 @@ public function prepare(INotification $notification, string $languageCode): INot
switch ($notification->getSubject()) {
case self::TYPE_MENTIONED:
$parameters = $notification->getSubjectParameters();
$sourceUser = $parameters[self::SUBJECT_MENTIONED_SOURCE_USER];
$sourceUserDisplayName = $this->userManager->getDisplayName($sourceUser);
$sourceUser = $parameters[self::SUBJECT_MENTIONED_SOURCE_USER] ?? null;
$sourceGuest = $parameters[self::SUBJECT_MENTIONED_SOURCE_GUEST] ?? null;

if (is_string($sourceUser)) {
$sourceUserDisplayName = $this->userManager->getDisplayName($sourceUser);
if ($sourceUserDisplayName === null) {
throw new UnknownNotificationException();
}
$sourceParameter = [
'type' => 'user',
'id' => $sourceUser,
'name' => $sourceUserDisplayName,
];
} elseif (is_string($sourceGuest)) {
$sourceParameter = [
'type' => 'guest',
'id' => 'guest',
'name' => $sourceGuest !== ''
? $l->t('%s (guest)', [$sourceGuest])
: $l->t('A guest'),
];
} else {
throw new AlreadyProcessedException();
}

$targetUser = $notification->getUser();
$fileId = (int)$notification->getObjectId();

if ($sourceUserDisplayName === null) {
throw new UnknownNotificationException();
}

try {
$userFolder = $this->rootFolder->getUserFolder($targetUser);
} catch (NotPermittedException|NoUserException $e) {
Expand All @@ -79,11 +99,7 @@ public function prepare(INotification $notification, string $languageCode): INot

$notification->setLink($fileLink);
$notification->setRichSubject($l->t('{user} has mentioned you in the text document {node}'), [
'user' => [
'type' => 'user',
'id' => $sourceUser,
'name' => $sourceUserDisplayName,
],
'user' => $sourceParameter,
'node' => [
'type' => 'file',
'id' => (string)$node->getId(),
Expand Down
13 changes: 10 additions & 3 deletions lib/Service/NotificationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,24 @@ public function __construct(IManager $manager, ITimeFactory $timeFactory, ?strin
$this->userId = $userId;
}

public function mention(int $fileId, string $userId): bool {
public function mention(int $fileId, string $userId, ?string $guestName = null): bool {
if ($this->userId === null && $guestName === null) {
return false;
}

if ($userId === $this->userId) {
// We allow self mentions in documents but do not notify about it
return false;
}

$source = $this->userId !== null
? [Notifier::SUBJECT_MENTIONED_SOURCE_USER => $this->userId]
: [Notifier::SUBJECT_MENTIONED_SOURCE_GUEST => $guestName];

$notification = $this->manager->createNotification();
$notification->setUser($userId)
->setApp('text')
->setSubject(Notifier::TYPE_MENTIONED, [
Notifier::SUBJECT_MENTIONED_SOURCE_USER => $this->userId,
->setSubject(Notifier::TYPE_MENTIONED, $source + [
Notifier::SUBJECT_MENTIONED_TARGET_USER => $userId,
])
->setObject('file', (string)$fileId);
Expand Down
112 changes: 112 additions & 0 deletions tests/unit/Notification/NotifierTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Text\Tests\Notification;

use OCA\Text\Notification\Notifier;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\L10N\IFactory;
use OCP\Notification\AlreadyProcessedException;
use OCP\Notification\IManager;
use OCP\Notification\INotification;
use OCP\Server;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;

class NotifierTest extends TestCase {
private IUserManager&MockObject $userManager;
private Notifier $notifier;

protected function setUp(): void {
parent::setUp();

$l10n = $this->createMock(IL10N::class);
$l10n->method('t')->willReturnCallback(fn (string $text, array $parameters = []) => vsprintf($text, $parameters));
$factory = $this->createMock(IFactory::class);
$factory->method('get')->willReturn($l10n);

$urlGenerator = $this->createMock(IURLGenerator::class);
$urlGenerator->method('getAbsoluteURL')->willReturn('http://localhost/apps/text/img/app-dark.svg');
$urlGenerator->method('linkToRouteAbsolute')->willReturn('http://localhost/f/42');

$file = $this->createMock(File::class);
$file->method('getId')->willReturn(42);
$file->method('getName')->willReturn('doc.md');
$file->method('getPath')->willReturn('/bob/files/doc.md');
$userFolder = $this->createMock(Folder::class);
$userFolder->method('getFirstNodeById')->with(42)->willReturn($file);
$userFolder->method('getRelativePath')->willReturn('/doc.md');
$rootFolder = $this->createMock(IRootFolder::class);
$rootFolder->method('getUserFolder')->with('bob')->willReturn($userFolder);

$this->userManager = $this->createMock(IUserManager::class);

$this->notifier = new Notifier(
$factory,
$this->userManager,
$urlGenerator,
$rootFolder,
$this->createMock(IEventDispatcher::class),
);
}

private function createMention(array $parameters): INotification {
return Server::get(IManager::class)->createNotification()
->setApp('text')
->setUser('bob')
->setSubject(Notifier::TYPE_MENTIONED, $parameters + ['targetUser' => 'bob'])
->setObject('file', '42');
}

public function testPrepareUserSource(): void {
$this->userManager->method('getDisplayName')->with('alice')->willReturn('Alice');

$notification = $this->notifier->prepare($this->createMention(['sourceUser' => 'alice']), 'en');

$this->assertSame(
['type' => 'user', 'id' => 'alice', 'name' => 'Alice'],
$notification->getRichSubjectParameters()['user'],
);
$this->assertSame('Alice has mentioned you in the text document /doc.md', $notification->getParsedSubject());
}

public static function dataPrepareGuestSource(): array {
return [
'named' => ['Gustav', 'Gustav (guest)'],
'unnamed' => ['', 'A guest'],
];
}

/**
* @dataProvider dataPrepareGuestSource
*/
public function testPrepareGuestSource(string $guestName, string $expectedName): void {
$this->userManager->expects($this->never())->method('getDisplayName');

$notification = $this->notifier->prepare($this->createMention(['sourceGuest' => $guestName]), 'en');

$this->assertSame(
['type' => 'guest', 'id' => 'guest', 'name' => $expectedName],
$notification->getRichSubjectParameters()['user'],
);
}

public function testPrepareWithoutSourceIsProcessed(): void {
$this->userManager->expects($this->never())->method('getDisplayName');
$this->expectException(AlreadyProcessedException::class);

$this->notifier->prepare($this->createMention(['sourceUser' => null]), 'en');
}
}
61 changes: 61 additions & 0 deletions tests/unit/Service/NotificationServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Text\Tests\Service;

use OCA\Text\Service\NotificationService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Notification\IManager;
use OCP\Notification\INotification;
use OCP\Server;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;

class NotificationServiceTest extends TestCase {
private IManager&MockObject $manager;
private ITimeFactory&MockObject $timeFactory;

protected function setUp(): void {
parent::setUp();

$this->manager = $this->createMock(IManager::class);
$this->manager->method('createNotification')
->willReturnCallback(fn () => Server::get(IManager::class)->createNotification());
$this->manager->method('getCount')->willReturn(0);

$this->timeFactory = $this->createMock(ITimeFactory::class);
$this->timeFactory->method('now')->willReturn(new \DateTimeImmutable());
}

public static function dataMention(): array {
return [
'user' => ['alice', null, ['sourceUser' => 'alice', 'targetUser' => 'bob']],
'guest' => [null, 'Gustav', ['sourceGuest' => 'Gustav', 'targetUser' => 'bob']],
];
}

/**
* @dataProvider dataMention
*/
public function testMention(?string $userId, ?string $guestName, array $expectedParameters): void {
$this->manager->expects($this->once())
->method('notify')
->with($this->callback(fn (INotification $n) => $n->getSubjectParameters() === $expectedParameters));

$service = new NotificationService($this->manager, $this->timeFactory, $userId);
$this->assertTrue($service->mention(42, 'bob', $guestName));
}

public function testMentionWithoutSource(): void {
$this->manager->expects($this->never())->method('notify');

$service = new NotificationService($this->manager, $this->timeFactory, null);
$this->assertFalse($service->mention(42, 'bob'));
}
}
Loading