diff --git a/resources/translations/en/app.php b/resources/translations/en/app.php index 4639b283145..5f6c0a5fe4c 100644 --- a/resources/translations/en/app.php +++ b/resources/translations/en/app.php @@ -2249,6 +2249,9 @@ 'by {creator}' => 'by {creator}', 'categories' => 'categories', 'category' => 'category', + 'comment_mention_body' => "Hey {{user.friendlyName|e}},\n\n{{author|e}} mentioned you in a comment on “{{subject|e}}”:\n\n{{comment}}\n\n<{{link}}>", + 'comment_mention_heading' => 'When someone mentions a user in a comment:', + 'comment_mention_subject' => 'You were mentioned in a comment', 'contains' => 'contains', 'content block' => 'content block', 'content blocks' => 'content blocks', diff --git a/src/Activity/ActivityComments.php b/src/Activity/ActivityComments.php new file mode 100644 index 00000000000..89ec0bdfaf8 --- /dev/null +++ b/src/Activity/ActivityComments.php @@ -0,0 +1,275 @@ +validate($markdown); + + $event = $this->events->record(new CommentCreated( + subject: $subject, + actor: $author, + site: $site, + markdown: $markdown, + authorId: $author->id, + authorLabel: $author->name, + mentions: $this->resolveMentions($markdown, $subject), + )); + + $this->scheduleMentionNotifications($event, $event->data['mentions']); + + return $event; + } + + public function edit( + ActivityEvent $comment, + User $author, + string $markdown, + ElementInterface $subject, + ): ActivityEvent { + $this->validate($markdown); + + return $this->mutate($comment, $author, CommentEdited::class, $markdown, $subject); + } + + public function delete(ActivityEvent $comment, User $actor): ActivityEvent + { + return $this->mutate($comment, $actor, CommentDeleted::class); + } + + public function canMention(User $user, ElementInterface $subject): bool + { + return $user->getStatus() === User::STATUS_ACTIVE + && $user->can('accessCp') + && Gate::forUser($user)->allows('view', $subject); + } + + public function render(ActivityEvent $version, User $viewer): HtmlString + { + $mentions = $this->mentions($version); + $users = User::find() + ->id($mentions->keys()->all()) + ->status(null) + ->collect() + ->keyBy('id'); + $html = $this->markdown->transform( + $version->data['markdown'], + function (Document $document) use ($mentions, $users, $viewer): void { + foreach ($this->mentionNodes($document) as $node) { + $reference = $node->getIdentifier(); + $mention = ctype_digit($reference) ? $mentions->get((int) $reference) : null; + + if ($mention === null) { + $node->replaceWith(new Text($node->getLabel() ?? "@$reference")); + + continue; + } + + $id = (int) $reference; + $user = $users->get($id); + $canView = $user !== null && Gate::forUser($viewer)->allows('view', $user); + $username = $canView ? ($user->username ?? $mention['username']) : $mention['username']; + + $node->replaceWith($canView && $user->getCpEditUrl() !== null + ? new Link($user->getCpEditUrl(), "@$username") + : new Text("@$username")); + } + }, + Markdown::FLAVOR_GFM_COMMENT, + ); + + return new HtmlString($this->htmlSanitizers->sanitize($html)); + } + + public function notificationText(ActivityEvent $version, User $viewer): string + { + $html = $this->render($version, $viewer)->toHtml(); + + return trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } + + /** @param class-string $eventType */ + private function mutate( + ActivityEvent $comment, + User $actor, + string $eventType, + ?string $markdown = null, + ?ElementInterface $liveSubject = null, + ): ActivityEvent { + return DB::transaction(function () use ($comment, $actor, $eventType, $markdown, $liveSubject): ActivityEvent { + $root = ActivityEvent::query() + ->eventTypes(CommentCreated::class) + ->whereNull('rootEventId') + ->lockForUpdate() + ->findOrFail($comment->id); + $current = ActivityEvent::query() + ->rootEvent($root) + ->newestFirst() + ->first() ?? $root; + + if ($current->eventType === CommentDeleted::class) { + throw ValidationException::withMessages([ + 'commentId' => t('This comment has been removed.'), + ]); + } + + $subject = new ActivitySubject( + $root->subjectType, + $root->subjectId, + $root->snapshots['subject']['label'], + ); + $site = $root->siteId === null ? null : Site::get($root->siteId); + + $mentions = $markdown === null + ? ($current->data['mentions'] ?? []) + : $this->resolveMentions($markdown, $liveSubject); + $event = $this->events->record(new $eventType( + subject: $subject, + actor: $actor, + site: $site, + markdown: $markdown ?? $current->data['markdown'], + authorId: $root->data['author']['id'], + authorLabel: $root->data['author']['label'], + mentions: $mentions, + ), rootEventId: $root->id); + + if ($markdown !== null) { + $previousMentionIds = array_column($current->data['mentions'] ?? [], 'id'); + $addedMentions = array_values(array_filter( + $mentions, + fn (array $mention): bool => ! in_array($mention['id'], $previousMentionIds, true), + )); + + $this->scheduleMentionNotifications($event, $addedMentions); + } + + return $event; + }); + } + + /** @param list $mentions */ + private function scheduleMentionNotifications(ActivityEvent $version, array $mentions): void + { + Notification::send( + UserModel::query()->findMany(array_column($mentions, 'id')), + new ActivityMentionNotification($version), + ); + } + + /** @return list */ + private function resolveMentions(string $markdown, ?ElementInterface $subject): array + { + $references = []; + $this->markdown->transform( + $markdown, + function (Document $document) use (&$references): void { + foreach ($this->mentionNodes($document) as $node) { + $references[] = $node->getIdentifier(); + } + }, + Markdown::FLAVOR_GFM_COMMENT, + ); + + if ($references === []) { + return []; + } + + $ids = collect($references) + ->filter(fn (string $id): bool => ctype_digit($id)) + ->map(fn (string $id): int => (int) $id) + ->unique() + ->values(); + $users = User::find() + ->id($ids->all()) + ->status(User::STATUS_ACTIVE) + ->collect() + ->keyBy('id'); + + return $ids + ->map(function (int $id) use ($subject, $users): ?array { + $user = $users->get($id); + + if ($subject === null || $user === null || ! $this->canMention($user, $subject)) { + return null; + } + + return ['id' => $user->id, 'username' => $user->username]; + }) + ->filter() + ->values() + ->all(); + } + + /** @return iterable */ + private function mentionNodes(Document $document): iterable + { + foreach (new NodeIterator($document) as $node) { + if ($node instanceof Mention) { + yield $node; + } + } + } + + /** @return Collection */ + private function mentions(ActivityEvent $version): Collection + { + $mentions = $version->data['mentions'] ?? []; + + if (! is_array($mentions)) { + throw new UnexpectedValueException('Activity comment mentions must be an array.'); + } + + return collect($mentions)->keyBy('id'); + } + + private function validate(string $markdown): void + { + if (blank($markdown)) { + throw ValidationException::withMessages([ + 'markdown' => t('Comment cannot be blank.'), + ]); + } + } +} diff --git a/src/Activity/ActivityEventRecorder.php b/src/Activity/ActivityEventRecorder.php index 0416144db32..300dd7207d7 100644 --- a/src/Activity/ActivityEventRecorder.php +++ b/src/Activity/ActivityEventRecorder.php @@ -20,7 +20,7 @@ public function __construct( private readonly Impersonation $impersonation, ) {} - public function record(ActivityEventTypeInterface $event): ActivityEvent + public function record(ActivityEventTypeInterface $event, ?string $rootEventId = null): ActivityEvent { $data = $event->data(); @@ -56,6 +56,7 @@ public function record(ActivityEventTypeInterface $event): ActivityEvent 'subjectType' => $subject?->type, 'subjectId' => $subject?->id, 'siteId' => $site?->id, + 'rootEventId' => $rootEventId, 'payload' => [ 'snapshots' => $snapshots, 'changes' => collect($event->changes())->toArray(), diff --git a/src/Activity/EventTypes/CommentCreated.php b/src/Activity/EventTypes/CommentCreated.php new file mode 100644 index 00000000000..1541f4c5129 --- /dev/null +++ b/src/Activity/EventTypes/CommentCreated.php @@ -0,0 +1,12 @@ + $mentions */ + public function __construct( + ElementInterface|ActivitySubject $subject, + User|ActivityActor $actor, + ?Site $site, + private readonly string $markdown, + private readonly int $authorId, + private readonly string $authorLabel, + private readonly array $mentions, + ) { + parent::__construct($subject, $actor, $site); + } + + public function data(): array + { + return [ + 'markdown' => $this->markdown, + 'author' => ['id' => $this->authorId, 'label' => $this->authorLabel], + 'mentions' => $this->mentions, + ]; + } +} diff --git a/src/Activity/Models/ActivityEvent.php b/src/Activity/Models/ActivityEvent.php index 5194f9530e5..df7f81d25c0 100644 --- a/src/Activity/Models/ActivityEvent.php +++ b/src/Activity/Models/ActivityEvent.php @@ -26,6 +26,7 @@ * @property string|null $subjectType * @property string|null $subjectId * @property int|null $siteId + * @property string|null $rootEventId * @property array{snapshots: array>, changes: list>, data: array} $payload * @property array> $snapshots * @property list $changes @@ -47,6 +48,7 @@ protected function casts(): array 'id' => 'string', 'actorId' => 'integer', 'siteId' => 'integer', + 'rootEventId' => 'string', 'payload' => 'array', 'occurredAt' => 'immutable_datetime', ]; @@ -132,6 +134,16 @@ protected function actor(Builder $query, ActivityActor $actor): Builder ->where('actorId', $actor->id); } + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function rootEvent(Builder $query, self|string $rootEvent): Builder + { + return $query->where('rootEventId', $rootEvent instanceof self ? $rootEvent->id : $rootEvent); + } + /** * @param Builder $query * @return Builder diff --git a/src/Cp/Notifications/CpNotification.php b/src/Cp/Notifications/CpNotification.php index 1bf9c885b2c..62d91d142b3 100644 --- a/src/Cp/Notifications/CpNotification.php +++ b/src/Cp/Notifications/CpNotification.php @@ -6,9 +6,12 @@ use Closure; use CraftCms\Cms\Cp\Data\NotificationButtonData; +use CraftCms\Cms\User\Contracts\CraftUser; use Illuminate\Notifications\Channels\DatabaseChannel; use Illuminate\Notifications\Notification; use Illuminate\Support\Arr; +use Laravel\SerializableClosure\SerializableClosure; +use UnexpectedValueException; class CpNotification extends Notification { @@ -16,51 +19,53 @@ class CpNotification extends Notification protected string $kind; - protected string|Closure|null $title = null; + protected string|SerializableClosure|null $title = null; - protected string|Closure|null $byline = null; + protected string|SerializableClosure|null $byline = null; - protected string|Closure|null $icon = null; + protected string|SerializableClosure|null $icon = null; - protected string|Closure|null $image = null; + protected string|SerializableClosure|null $image = null; - protected string|Closure|null $imageAlt = null; + protected string|SerializableClosure|null $imageAlt = null; - protected string|Closure|null $url = null; + protected string|SerializableClosure|null $url = null; - /** @var list|Closure(object): list */ - protected array|Closure $buttons = []; + /** @var list|SerializableClosure */ + protected array|SerializableClosure $buttons = []; - /** @param string|Closure(object): string $message */ - public function __construct( - protected string|Closure $message, - ) { + protected string|SerializableClosure $message; + + /** @param string|Closure(CraftUser): string $message */ + public function __construct(string|Closure $message) + { $this->kind = static::class; + $this->message = $this->serializable($message); } /** @return class-string[] */ - public function via(object $notifiable): array + public function via(CraftUser $notifiable): array { return [DatabaseChannel::class]; } /** @return array */ - public function toDatabase(object $notifiable): array + public function toDatabase(CraftUser $notifiable): array { return Arr::whereNotNull([ 'kind' => $this->kind, - 'title' => value($this->title, $notifiable), - 'message' => value($this->message, $notifiable), - 'byline' => value($this->byline, $notifiable), - 'icon' => value($this->icon, $notifiable), - 'image' => value($this->image, $notifiable), - 'imageAlt' => value($this->imageAlt, $notifiable), - 'url' => value($this->url, $notifiable), - 'buttons' => collect(value($this->buttons, $notifiable))->toArray(), + 'title' => $this->resolve($this->title, $notifiable), + 'message' => $this->resolve($this->message, $notifiable), + 'byline' => $this->resolve($this->byline, $notifiable), + 'icon' => $this->resolve($this->icon, $notifiable), + 'image' => $this->resolve($this->image, $notifiable), + 'imageAlt' => $this->resolve($this->imageAlt, $notifiable), + 'url' => $this->resolve($this->url, $notifiable), + 'buttons' => collect($this->resolveButtons($notifiable))->toArray(), ]); } - public function databaseType(object $notifiable): string + public function databaseType(CraftUser $notifiable): string { return self::TYPE; } @@ -72,55 +77,81 @@ public function kind(string $kind): static return $this; } - /** @param string|Closure(object): string|null $title */ + /** @param string|Closure(CraftUser): string|null $title */ public function title(string|Closure|null $title): static { - $this->title = $title; + $this->title = $this->serializable($title); return $this; } - /** @param string|Closure(object): string|null $byline */ + /** @param string|Closure(CraftUser): string|null $byline */ public function byline(string|Closure|null $byline): static { - $this->byline = $byline; + $this->byline = $this->serializable($byline); return $this; } - /** @param string|Closure(object): string|null $icon */ + /** @param string|Closure(CraftUser): string|null $icon */ public function icon(string|Closure|null $icon): static { - $this->icon = $icon; + $this->icon = $this->serializable($icon); return $this; } /** - * @param string|Closure(object): string $url - * @param string|Closure(object): string $alt + * @param string|Closure(CraftUser): string $url + * @param string|Closure(CraftUser): string $alt */ public function image(string|Closure $url, string|Closure $alt): static { - $this->image = $url; - $this->imageAlt = $alt; + $this->image = $this->serializable($url); + $this->imageAlt = $this->serializable($alt); return $this; } - /** @param string|Closure(object): string|null $url */ + /** @param string|Closure(CraftUser): string|null $url */ public function url(string|Closure|null $url): static { - $this->url = $url; + $this->url = $this->serializable($url); return $this; } - /** @param list|Closure(object): list $buttons */ + /** @param list|Closure(CraftUser): list $buttons */ public function buttons(array|Closure $buttons): static { - $this->buttons = $buttons; + $this->buttons = $buttons instanceof Closure ? new SerializableClosure($buttons) : $buttons; return $this; } + + private function serializable(string|Closure|null $value): string|SerializableClosure|null + { + return $value instanceof Closure ? new SerializableClosure($value) : $value; + } + + private function resolve(string|SerializableClosure|null $value, CraftUser $notifiable): mixed + { + return $value instanceof SerializableClosure ? $value($notifiable) : $value; + } + + /** @return array */ + private function resolveButtons(CraftUser $notifiable): array + { + if (is_array($this->buttons)) { + return $this->buttons; + } + + $buttons = ($this->buttons)($notifiable); + + if (! is_array($buttons)) { + throw new UnexpectedValueException('CP notification button callbacks must return an array.'); + } + + return $buttons; + } } diff --git a/src/Database/Migrations/2026_08_25_000000_create_activityevents_table.php b/src/Database/Migrations/2026_08_25_000000_create_activityevents_table.php index f6cc7f45a81..a01084dce1b 100644 --- a/src/Database/Migrations/2026_08_25_000000_create_activityevents_table.php +++ b/src/Database/Migrations/2026_08_25_000000_create_activityevents_table.php @@ -24,6 +24,7 @@ public function up(): void $table->string('subjectType')->nullable(); $table->string('subjectId')->nullable(); $table->unsignedBigInteger('siteId')->nullable(); + $table->unsignedBigInteger('rootEventId')->nullable(); $table->jsonb('payload'); $table->dateTime('occurredAt'); }); @@ -31,6 +32,12 @@ public function up(): void Schema::createIndex(Table::ACTIVITYEVENTS, ['actorType', 'actorId']); Schema::createIndex(Table::ACTIVITYEVENTS, ['subjectType', 'subjectId', 'siteId', 'occurredAt', 'id']); Schema::createIndex(Table::ACTIVITYEVENTS, ['occurredAt', 'id']); + Schema::createIndex(Table::ACTIVITYEVENTS, ['rootEventId', 'occurredAt', 'id']); + + Schema::table(Table::ACTIVITYEVENTS, fn (Blueprint $table) => $table->foreign('rootEventId') + ->references('id') + ->on(Table::ACTIVITYEVENTS) + ->cascadeOnDelete()); } public function down(): void diff --git a/src/Database/Migrations/Install.php b/src/Database/Migrations/Install.php index ef868bdb90b..2ff6ebbb023 100644 --- a/src/Database/Migrations/Install.php +++ b/src/Database/Migrations/Install.php @@ -219,6 +219,7 @@ public function createTables(?Logger $logger = null): void $table->string('subjectType')->nullable(); $table->string('subjectId')->nullable(); $table->unsignedBigInteger('siteId')->nullable(); + $table->unsignedBigInteger('rootEventId')->nullable(); $table->jsonb('payload'); $table->dateTime('occurredAt'); }); @@ -1037,6 +1038,7 @@ public function createIndexes(): void Schema::createIndex(Table::ACTIVITYEVENTS, ['actorType', 'actorId']); Schema::createIndex(Table::ACTIVITYEVENTS, ['subjectType', 'subjectId', 'siteId', 'occurredAt', 'id']); Schema::createIndex(Table::ACTIVITYEVENTS, ['occurredAt', 'id']); + Schema::createIndex(Table::ACTIVITYEVENTS, ['rootEventId', 'occurredAt', 'id']); Schema::createIndex(Table::ASSETINDEXDATA, ['sessionId', 'volumeId']); Schema::createIndex(Table::ASSETINDEXDATA, ['sessionId', 'status', 'id']); Schema::createIndex(Table::ASSETINDEXDATA, ['volumeId']); @@ -1182,6 +1184,7 @@ public function createIndexes(): void public function addForeignKeys(): void { + Schema::table(Table::ACTIVITYEVENTS, fn (Blueprint $table) => $table->foreign('rootEventId')->references('id')->on(Table::ACTIVITYEVENTS)->cascadeOnDelete()); Schema::table(Table::ADDRESSES, fn (Blueprint $table) => $table->foreign('id')->references('id')->on(Table::ELEMENTS)->cascadeOnDelete()); Schema::table(Table::ADDRESSES, fn (Blueprint $table) => $table->foreign('primaryOwnerId')->references('id')->on(Table::ELEMENTS)->cascadeOnDelete()); Schema::table(Table::ASSETINDEXDATA, fn (Blueprint $table) => $table->foreign('volumeId')->references('id')->on(Table::VOLUMES)->cascadeOnDelete()); diff --git a/src/GarbageCollection/Actions/PurgeExpiredActivity.php b/src/GarbageCollection/Actions/PurgeExpiredActivity.php index b76d978bfc0..3ac07ca8eee 100644 --- a/src/GarbageCollection/Actions/PurgeExpiredActivity.php +++ b/src/GarbageCollection/Actions/PurgeExpiredActivity.php @@ -21,6 +21,7 @@ public function __invoke(): void function () { DB::table(Table::ACTIVITYEVENTS) ->select('id') + ->whereNull('rootEventId') ->where('occurredAt', '<', now()->subSeconds($this->generalConfig->activityRetentionDuration)) ->orderBy('id') ->chunkById( diff --git a/src/Markdown/CommonMark/Extensions/UserMentionExtension.php b/src/Markdown/CommonMark/Extensions/UserMentionExtension.php new file mode 100644 index 00000000000..0e206ab0b5e --- /dev/null +++ b/src/Markdown/CommonMark/Extensions/UserMentionExtension.php @@ -0,0 +1,36 @@ +addEventListener(DocumentParsedEvent::class, $this(...)); + } + + public function __invoke(DocumentParsedEvent $event): void + { + foreach ($event->getDocument()->iterator() as $node) { + if (! $node instanceof Link || ! str_starts_with($node->getUrl(), self::URL_PREFIX)) { + continue; + } + + $mention = new Mention('user', '@', substr($node->getUrl(), strlen(self::URL_PREFIX))); + $mention->setUrl($node->getUrl()); + $mention->setTitle($node->getTitle()); + $mention->replaceChildren($node->children()); + $node->replaceWith($mention); + } + } +} diff --git a/src/Markdown/Flavors/GfmFlavor.php b/src/Markdown/Flavors/GfmFlavor.php index b74040155f2..d6fea7a2aca 100644 --- a/src/Markdown/Flavors/GfmFlavor.php +++ b/src/Markdown/Flavors/GfmFlavor.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Markdown\Flavors; +use CraftCms\Cms\Markdown\CommonMark\Extensions\UserMentionExtension; use CraftCms\Cms\Markdown\MarkdownOptions; use League\CommonMark\Environment\Environment; use League\CommonMark\Extension\Autolink\AutolinkExtension; @@ -21,6 +22,7 @@ public function __construct(private readonly string $softBreak = "\n") {} public function __invoke(MarkdownOptions $options): MarkdownConverter { $environment = $this->environment($options, $this->softBreak); + $environment->addExtension(new UserMentionExtension); if ($options->inlineOnly) { $environment diff --git a/src/Markdown/Markdown.php b/src/Markdown/Markdown.php index 42d2640134c..761f9d41c2f 100644 --- a/src/Markdown/Markdown.php +++ b/src/Markdown/Markdown.php @@ -11,6 +11,9 @@ use Illuminate\Container\Attributes\Singleton; use InvalidArgumentException; use League\CommonMark\MarkdownConverter; +use League\CommonMark\Node\Block\Document; +use League\CommonMark\Parser\MarkdownParser; +use League\CommonMark\Renderer\HtmlRenderer; #[Singleton] class Markdown @@ -79,6 +82,22 @@ public function parseParagraph(string $markdown, ?string $flavor = null, bool $a )), "\n"); } + /** @param callable(Document): void $transform */ + public function transform(string $markdown, callable $transform, ?string $flavor = null): string + { + if (ltrim($markdown) === '') { + return ''; + } + + $options = new MarkdownOptions(flavor: $flavor); + $environment = $this->converter($options)->getEnvironment(); + $document = new MarkdownParser($environment)->parse($markdown); + + $transform($document); + + return new HtmlRenderer($environment)->renderDocument($document)->getContent(); + } + public function convert(string $markdown, MarkdownOptions $options): string { if (ltrim($markdown) === '') { @@ -86,7 +105,7 @@ public function convert(string $markdown, MarkdownOptions $options): string } return $this->converter($options) - ->convert(str_replace(["\r\n", "\n\r", "\r"], "\n", $markdown)) + ->convert($markdown) ->getContent(); } diff --git a/src/SystemMessage/SystemMessageCatalog.php b/src/SystemMessage/SystemMessageCatalog.php index 8743182dc2b..d551c74bf0d 100644 --- a/src/SystemMessage/SystemMessageCatalog.php +++ b/src/SystemMessage/SystemMessageCatalog.php @@ -29,7 +29,7 @@ class SystemMessageCatalog public function __construct( private readonly Container $container, ) { - foreach (['account_activation', 'verify_new_email', 'forgot_password', 'test_email'] as $key) { + foreach (['account_activation', 'comment_mention', 'verify_new_email', 'forgot_password', 'test_email'] as $key) { $this->register($key, fn () => new SystemMessage([ 'key' => $key, 'heading' => t("{$key}_heading"), diff --git a/src/User/Notifications/ActivityMentionNotification.php b/src/User/Notifications/ActivityMentionNotification.php new file mode 100644 index 00000000000..6e596b960a9 --- /dev/null +++ b/src/User/Notifications/ActivityMentionNotification.php @@ -0,0 +1,102 @@ + app(ActivityComments::class) + ->notificationText($event, $notifiable->asElement()), + ); + + $this->queue = Cms::config()->queueName; + $this + ->title('comment_mention_subject') + ->byline($event->data['author']['label']) + ->icon('comment') + ->url($this->subject()?->getCpEditUrl()); + } + + /** @return class-string[] */ + #[\Override] + public function via(CraftUser $notifiable): array + { + return [...parent::via($notifiable), MailChannel::class]; + } + + public function shouldSend(CraftUser $notifiable, string $channel): bool + { + $recipient = User::find() + ->id($notifiable->getCraftUserId()) + ->status(User::STATUS_ACTIVE) + ->one(); + $subject = $this->subject(); + + return $recipient !== null + && $subject !== null + && app(ActivityComments::class)->canMention($recipient, $subject); + } + + public function toMail(CraftUser $notifiable): SystemMessageMailable + { + $subject = $this->subject(); + $editUrl = $subject?->getCpEditUrl(); + + if ($subject === null || $editUrl === null) { + throw new LogicException('Activity mention notification subjects must have a control panel edit URL.'); + } + + $recipient = $notifiable->asElement(); + $mailable = app(SystemMessages::class)->mailable( + key: 'comment_mention', + user: $recipient, + variables: [ + 'author' => $this->event->data['author']['label'], + 'subject' => $this->event->snapshots['subject']['label'], + 'comment' => app(ActivityComments::class)->notificationText($this->event, $recipient), + 'link' => Template::raw(Url::cpUrl($editUrl)), + ], + ); + $mailable->siteId = $this->event->siteId; + + return $mailable; + } + + private function subject(): ?ElementInterface + { + if ($this->event->subjectId === null) { + return null; + } + + return Elements::getElementByUid( + $this->event->subjectId, + $this->event->subjectType, + $this->event->siteId, + ); + } +} diff --git a/tests/Feature/Activity/ActivityCommentsTest.php b/tests/Feature/Activity/ActivityCommentsTest.php new file mode 100644 index 00000000000..5229fd47e33 --- /dev/null +++ b/tests/Feature/Activity/ActivityCommentsTest.php @@ -0,0 +1,180 @@ +create(); + Sites::refreshSites(); + Notification::fake(); + + $this->comments = app(ActivityComments::class); + $this->author = User::findOne(); + $this->entry = Entry::factory()->createElement(['title' => 'Release notes']); + $this->site = Sites::getSiteById($this->entry->siteId); + $this->mentionPermissions = [ + 'accessCp', + "editSite:{$this->site->uid}", + "viewEntries:{$this->entry->getSection()->uid}", + "viewPeerEntries:{$this->entry->getSection()->uid}", + ]; + $this->mentioned = UserModel::factory() + ->withPermissions($this->mentionPermissions) + ->createElement(['admin' => false, 'username' => 'grace']); + + $this->actingAs($this->author); + DB::table(Table::ACTIVITYEVENTS)->delete(); +}); + +it('records immutable comment lifecycle versions', function () { + $created = $this->comments->create($this->entry, $this->author, null, 'First version'); + $edited = $this->comments->edit($created, $this->author, 'Second version', $this->entry); + $deleted = $this->comments->delete($created, $this->author); + + expect($created->eventType)->toBe(CommentCreated::class) + ->and($created->rootEventId)->toBeNull() + ->and($created->siteId)->toBeNull() + ->and($edited->eventType)->toBe(CommentEdited::class) + ->and($edited->rootEventId)->toBe($created->id) + ->and($deleted->eventType)->toBe(CommentDeleted::class) + ->and($deleted->rootEventId)->toBe($created->id) + ->and($deleted->data['markdown'])->toBe('Second version'); + + expect(fn () => $this->comments->edit($created, $this->author, 'Resurrected', $this->entry)) + ->toThrow(ValidationException::class); +}); + +it('stores and renders eligible mentions and ignores invalid mentions', function () { + $comment = $this->comments->create( + $this->entry, + $this->author, + $this->site, + "Hello [@grace](craft-user:{$this->mentioned->id}) and @plain.", + ); + $document = new DOMDocument; + $document->loadHTML( + $this->comments->render($comment, $this->author)->toHtml(), + LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD, + ); + + expect($comment->data['mentions'])->toBe([[ + 'id' => $this->mentioned->id, + 'username' => 'grace', + ]]) + ->and($document->textContent)->toBe('Hello @grace and @plain.'); + + UserModel::query()->whereKey($this->mentioned->id)->update(['username' => 'hopper']); + $document->loadHTML( + $this->comments->render($comment, $this->author)->toHtml(), + LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD, + ); + + expect($document->textContent)->toBe('Hello @hopper and @plain.'); + + $ineligible = UserModel::factory() + ->withPermissions(['accessCp']) + ->createElement(['admin' => false, 'username' => 'ineligible']); + + $ignored = $this->comments->create( + $this->entry, + $this->author, + $this->site, + "Hello [@ineligible](craft-user:{$ineligible->id}) and [@invalid](craft-user:not-a-number).", + ); + $document->loadHTML( + $this->comments->render($ignored, $this->author)->toHtml(), + LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD, + ); + + expect($ignored->data['mentions'])->toBe([]) + ->and($document->textContent)->toBe('Hello @ineligible and @invalid.') + ->and(fn () => $this->comments->create( + $this->entry, + $this->author, + $this->site, + " \n\t ", + ))->toThrow(ValidationException::class); +}); + +it('normalizes CommonMark mention links in notification comments', function (Closure $mention) { + $comment = $this->comments->create( + $this->entry, + $this->author, + $this->site, + sprintf('Hello **A & B**, %s.', $mention($this->mentioned->id)), + ); + $notifiable = UserModel::query()->findOrFail($this->mentioned->id); + + expect(new ActivityMentionNotification($comment)->toMail($notifiable)->variables['comment']) + ->toBe('Hello A & B, @grace.'); +})->with([ + 'plain destination' => fn (int $id): string => "[@grace](craft-user:$id)", + 'angle destination' => fn (int $id): string => "[@grace]()", + 'destination with title' => fn (int $id): string => "[@grace](craft-user:$id \"Grace Hopper\")", +]); + +it('rechecks complete mention eligibility before sending', function () { + $comment = $this->comments->create( + $this->entry, + $this->author, + $this->site, + "Hello [@grace](craft-user:{$this->mentioned->id}).", + ); + $notification = unserialize(serialize(new ActivityMentionNotification($comment))); + $notifiable = UserModel::query()->findOrFail($this->mentioned->id); + + expect($notification)->toBeInstanceOf(CpNotification::class) + ->and($notification->via($notifiable))->toBe([DatabaseChannel::class, MailChannel::class]) + ->and($notification->toDatabase($notifiable))->toMatchArray([ + 'title' => 'comment_mention_subject', + 'message' => 'Hello @grace.', + 'byline' => $this->author->name, + 'icon' => 'comment', + 'url' => $this->entry->getCpEditUrl(), + ]) + ->and($notification->shouldSend($notifiable, 'mail'))->toBeTrue(); + + UserPermissions::saveUserPermissions( + $this->mentioned->id, + array_values(array_diff($this->mentionPermissions, ['accessCp'])), + ); + UserPermissions::reset(); + + expect($notification->shouldSend($notifiable, 'mail'))->toBeFalse(); +}); + +it('notifies users added by comment edits', function () { + $added = UserModel::factory() + ->withPermissions($this->mentionPermissions) + ->createElement(['admin' => false, 'username' => 'ada']); + $markdown = "Hello [@grace](craft-user:{$this->mentioned->id})."; + $comment = $this->comments->create($this->entry, $this->author, $this->site, $markdown); + $editedMarkdown = "$markdown And [@ada](craft-user:{$added->id})."; + + $this->comments->edit($comment, $this->author, $editedMarkdown, $this->entry); + $this->comments->edit($comment, $this->author, $editedMarkdown, $this->entry); + + Notification::assertSentTimes(ActivityMentionNotification::class, 2); + Notification::assertSentTo(UserModel::query()->findOrFail($this->mentioned->id), ActivityMentionNotification::class); + Notification::assertSentTo(UserModel::query()->findOrFail($added->id), ActivityMentionNotification::class); +}); diff --git a/tests/Feature/Cp/Notifications/NotificationCenterTest.php b/tests/Feature/Cp/Notifications/NotificationCenterTest.php index f3ce425e0f4..54568df9e69 100644 --- a/tests/Feature/Cp/Notifications/NotificationCenterTest.php +++ b/tests/Feature/Cp/Notifications/NotificationCenterTest.php @@ -71,6 +71,20 @@ expect(app(NotificationCenter::class)->get())->toBe([]); }); +it('serializes closure values', function () { + $user = User::query()->firstOrFail(); + $notification = new CpNotification( + static fn (CraftUser $notifiable): string => "Hello {$notifiable->asElement()->username}", + )->title(static fn (CraftUser $notifiable): string => "For {$notifiable->asElement()->email}"); + + $notification = unserialize(serialize($notification)); + + expect($notification->toDatabase($user))->toMatchArray([ + 'message' => "Hello {$user->username}", + 'title' => "For {$user->email}", + ]); +}); + class ConfiguredNotificationUser extends User { #[Override] diff --git a/tests/Feature/GarbageCollection/Actions/PurgeExpiredActivityTest.php b/tests/Feature/GarbageCollection/Actions/PurgeExpiredActivityTest.php index e7160daa847..38478f9002c 100644 --- a/tests/Feature/GarbageCollection/Actions/PurgeExpiredActivityTest.php +++ b/tests/Feature/GarbageCollection/Actions/PurgeExpiredActivityTest.php @@ -3,12 +3,17 @@ declare(strict_types=1); use CraftCms\Cms\Activity\Activities; +use CraftCms\Cms\Activity\ActivityComments; use CraftCms\Cms\Activity\Data\ActivitySubject; use CraftCms\Cms\Activity\EventTypes\ElementCreated; use CraftCms\Cms\Activity\EventTypes\ElementUpdated; use CraftCms\Cms\Activity\Models\ActivityEvent; use CraftCms\Cms\Cms; +use CraftCms\Cms\Entry\Models\Entry; use CraftCms\Cms\GarbageCollection\Actions\PurgeExpiredActivity; +use CraftCms\Cms\Site\Models\Site; +use CraftCms\Cms\Support\Facades\Sites; +use CraftCms\Cms\User\Models\User; use Illuminate\Support\Facades\Date; afterEach(fn () => Date::setTestNow()); @@ -25,20 +30,25 @@ expect(ActivityEvent::query()->whereKey($event->id)->exists())->toBeTrue(); }); -it('purges activity older than the retention duration', function () { +it('purges eligible standalone events and complete comment groups', function () { Cms::config()->activityRetentionDuration(3600); $activities = app(Activities::class); - $subject = new ActivitySubject('document', 'one', 'Document one'); + $comments = app(ActivityComments::class); + $author = User::factory()->createElement(); + $entry = Entry::factory()->createElement(); + $site = Sites::getSiteById(Site::factory()->create()->id); Date::setTestNow('2026-08-26 10:00:00'); - $expired = $activities->record(new ElementCreated(subject: $subject)); + $expired = $activities->record(new ElementCreated(subject: $entry)); + $comment = $comments->create($entry, $author, $site, 'Original comment'); Date::setTestNow('2026-08-26 12:00:00'); - $retained = $activities->record(new ElementUpdated(subject: $subject)); + $comments->edit($comment, $author, 'Edited comment', $entry); + $retained = $activities->record(new ElementUpdated(subject: $entry)); app(PurgeExpiredActivity::class)(); - expect(ActivityEvent::query()->whereKey($retained->id)->exists())->toBeTrue() + expect(ActivityEvent::query()->pluck('id')->all())->toBe([$retained->id]) ->and(ActivityEvent::query()->whereKey($expired->id)->exists())->toBeFalse(); });