diff --git a/extensions/mentions/extend.php b/extensions/mentions/extend.php index 178330e33..794503693 100644 --- a/extensions/mentions/extend.php +++ b/extensions/mentions/extend.php @@ -12,10 +12,11 @@ namespace Flarum\Mentions; use Flarum\Api\Controller; use Flarum\Api\Serializer\BasicPostSerializer; use Flarum\Api\Serializer\BasicUserSerializer; +use Flarum\Api\Serializer\CurrentUserSerializer; +use Flarum\Api\Serializer\GroupSerializer; use Flarum\Api\Serializer\PostSerializer; use Flarum\Extend; -use Flarum\Mentions\Notification\PostMentionedBlueprint; -use Flarum\Mentions\Notification\UserMentionedBlueprint; +use Flarum\Group\Group; use Flarum\Post\Event\Deleted; use Flarum\Post\Event\Hidden; use Flarum\Post\Event\Posted; @@ -37,13 +38,16 @@ return [ ->configure(ConfigureMentions::class) ->render(Formatter\FormatPostMentions::class) ->render(Formatter\FormatUserMentions::class) + ->render(Formatter\FormatGroupMentions::class) ->unparse(Formatter\UnparsePostMentions::class) - ->unparse(Formatter\UnparseUserMentions::class), + ->unparse(Formatter\UnparseUserMentions::class) + ->parse(Formatter\CheckPermissions::class), (new Extend\Model(Post::class)) ->belongsToMany('mentionedBy', Post::class, 'post_mentions_post', 'mentions_post_id', 'post_id') ->belongsToMany('mentionsPosts', Post::class, 'post_mentions_post', 'post_id', 'mentions_post_id') - ->belongsToMany('mentionsUsers', User::class, 'post_mentions_user', 'post_id', 'mentions_user_id'), + ->belongsToMany('mentionsUsers', User::class, 'post_mentions_user', 'post_id', 'mentions_user_id') + ->belongsToMany('mentionsGroups', Group::class, 'post_mentions_group', 'post_id', 'mentions_group_id'), new Extend\Locales(__DIR__.'/locale'), @@ -51,25 +55,28 @@ return [ ->namespace('flarum-mentions', __DIR__.'/views'), (new Extend\Notification()) - ->type(PostMentionedBlueprint::class, PostSerializer::class, ['alert']) - ->type(UserMentionedBlueprint::class, PostSerializer::class, ['alert']), + ->type(Notification\PostMentionedBlueprint::class, PostSerializer::class, ['alert']) + ->type(Notification\UserMentionedBlueprint::class, PostSerializer::class, ['alert']) + ->type(Notification\GroupMentionedBlueprint::class, PostSerializer::class, ['alert']), (new Extend\ApiSerializer(BasicPostSerializer::class)) ->hasMany('mentionedBy', BasicPostSerializer::class) ->hasMany('mentionsPosts', BasicPostSerializer::class) - ->hasMany('mentionsUsers', BasicUserSerializer::class), + ->hasMany('mentionsUsers', BasicUserSerializer::class) + ->hasMany('mentionsGroups', GroupSerializer::class), (new Extend\ApiController(Controller\ShowDiscussionController::class)) ->addInclude(['posts.mentionedBy', 'posts.mentionedBy.user', 'posts.mentionedBy.discussion']) ->load([ 'posts.mentionsUsers', 'posts.mentionsPosts', 'posts.mentionsPosts.user', 'posts.mentionedBy', 'posts.mentionedBy.mentionsPosts', 'posts.mentionedBy.mentionsPosts.user', 'posts.mentionedBy.mentionsUsers', + 'posts.mentionsGroups' ]), (new Extend\ApiController(Controller\ListDiscussionsController::class)) ->load([ - 'firstPost.mentionsUsers', 'firstPost.mentionsPosts', 'firstPost.mentionsPosts.user', - 'lastPost.mentionsUsers', 'lastPost.mentionsPosts', 'lastPost.mentionsPosts.user' + 'firstPost.mentionsUsers', 'firstPost.mentionsPosts', 'firstPost.mentionsPosts.user', 'firstPost.mentionsGroups', + 'lastPost.mentionsUsers', 'lastPost.mentionsPosts', 'lastPost.mentionsPosts.user', 'lastPost.mentionsGroups' ]), (new Extend\ApiController(Controller\ShowPostController::class)) @@ -80,13 +87,16 @@ return [ ->load([ 'mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionedBy', 'mentionedBy.mentionsPosts', 'mentionedBy.mentionsPosts.user', 'mentionedBy.mentionsUsers', + 'mentionsGroups' ]), (new Extend\ApiController(Controller\CreatePostController::class)) - ->addInclude(['mentionsPosts', 'mentionsPosts.mentionedBy']), + ->addInclude(['mentionsPosts', 'mentionsPosts.mentionedBy']) + ->addOptionalInclude('mentionsGroups'), (new Extend\ApiController(Controller\UpdatePostController::class)) - ->addInclude(['mentionsPosts', 'mentionsPosts.mentionedBy']), + ->addInclude(['mentionsPosts', 'mentionsPosts.mentionedBy']) + ->addOptionalInclude('mentionsGroups'), (new Extend\ApiController(Controller\AbstractSerializeController::class)) ->prepareDataForSerialization(FilterVisiblePosts::class), @@ -103,4 +113,9 @@ return [ (new Extend\Filter(PostFilterer::class)) ->addFilter(Filter\MentionedFilter::class), + + (new Extend\ApiSerializer(CurrentUserSerializer::class)) + ->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user, array $attributes): bool { + return $user->can('mentionGroups'); + }) ]; diff --git a/extensions/mentions/js/src/admin/index.js b/extensions/mentions/js/src/admin/index.js index e34d92cbc..f8bc9c9fc 100644 --- a/extensions/mentions/js/src/admin/index.js +++ b/extensions/mentions/js/src/admin/index.js @@ -1,10 +1,20 @@ import app from 'flarum/admin/app'; app.initializers.add('flarum-mentions', function () { - app.extensionData.for('flarum-mentions').registerSetting({ - setting: 'flarum-mentions.allow_username_format', - type: 'boolean', - label: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_label'), - help: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_text'), - }); + app.extensionData + .for('flarum-mentions') + .registerSetting({ + setting: 'flarum-mentions.allow_username_format', + type: 'boolean', + label: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_label'), + help: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_text'), + }) + .registerPermission( + { + permission: 'mentionGroups', + label: app.translator.trans('flarum-mentions.admin.permissions.mention_groups_label'), + icon: 'fas fa-at', + }, + 'start' + ); }); diff --git a/extensions/mentions/js/src/forum/addComposerAutocomplete.js b/extensions/mentions/js/src/forum/addComposerAutocomplete.js index f1eec3122..78588a4da 100644 --- a/extensions/mentions/js/src/forum/addComposerAutocomplete.js +++ b/extensions/mentions/js/src/forum/addComposerAutocomplete.js @@ -10,6 +10,8 @@ import highlight from 'flarum/common/helpers/highlight'; import KeyboardNavigatable from 'flarum/forum/utils/KeyboardNavigatable'; import { truncate } from 'flarum/common/utils/string'; import { throttle } from 'flarum/common/utils/throttleDebounce'; +import Badge from 'flarum/common/components/Badge'; +import Group from 'flarum/common/models/Group'; import AutocompleteDropdown from './fragments/AutocompleteDropdown'; import getMentionText from './utils/getMentionText'; @@ -29,6 +31,7 @@ const throttledSearch = throttle( buildSuggestions(); }); + searched.push(typedLower); } } @@ -66,6 +69,13 @@ export default function addComposerAutocomplete() { const returnedUsers = Array.from(app.store.all('users')); const returnedUserIds = new Set(returnedUsers.map((u) => u.id())); + // Store groups, but exclude the two virtual groups - 'Guest' and 'Member'. + const returnedGroups = Array.from( + app.store.all('groups').filter((group) => { + return group.id() != Group.GUEST_ID && group.id() != Group.MEMBER_ID; + }) + ); + const applySuggestion = (replacement) => { this.attrs.composer.editor.replaceBeforeCursor(absMentionStart - 1, replacement + ' '); @@ -124,12 +134,41 @@ export default function addComposerAutocomplete() { ); }; + const makeGroupSuggestion = function (group, replacement, content, className = '') { + let groupName = group.namePlural().toLowerCase(); + + if (typed) { + groupName = highlight(groupName, typed); + } + + return ( + + ); + }; + const userMatches = function (user) { const names = [user.username(), user.displayName()]; return names.some((name) => name.toLowerCase().substr(0, typed.length) === typed); }; + const groupMatches = function (group) { + const names = [group.nameSingular(), group.namePlural()]; + + return names.some((name) => name.toLowerCase().substr(0, typed.length) === typed); + }; + const buildSuggestions = () => { const suggestions = []; @@ -141,6 +180,15 @@ export default function addComposerAutocomplete() { suggestions.push(makeSuggestion(user, getMentionText(user), '', 'MentionsDropdown-user')); }); + + // ... or groups. + if (app.session?.user?.canMentionGroups()) { + returnedGroups.forEach((group) => { + if (!groupMatches(group)) return; + + suggestions.push(makeGroupSuggestion(group, getMentionText(undefined, undefined, group), '', 'MentionsDropdown-group')); + }); + } } // If the user is replying to a discussion, or if they are editing a diff --git a/extensions/mentions/js/src/forum/compat.js b/extensions/mentions/js/src/forum/compat.js index 7ada97757..ee22c6773 100644 --- a/extensions/mentions/js/src/forum/compat.js +++ b/extensions/mentions/js/src/forum/compat.js @@ -1,3 +1,4 @@ +import GroupMentionedNotification from './components/GroupMentionedNotification'; import MentionsUserPage from './components/MentionsUserPage'; import PostMentionedNotification from './components/PostMentionedNotification'; import UserMentionedNotification from './components/UserMentionedNotification'; @@ -13,6 +14,7 @@ export default { 'mentions/components/MentionsUserPage': MentionsUserPage, 'mentions/components/PostMentionedNotification': PostMentionedNotification, 'mentions/components/UserMentionedNotification': UserMentionedNotification, + 'mentions/components/GroupMentionedNotification': GroupMentionedNotification, 'mentions/fragments/AutocompleteDropdown': AutocompleteDropdown, 'mentions/fragments/PostQuoteButton': PostQuoteButton, 'mentions/utils/getCleanDisplayName': getCleanDisplayName, diff --git a/extensions/mentions/js/src/forum/components/GroupMentionedNotification.js b/extensions/mentions/js/src/forum/components/GroupMentionedNotification.js new file mode 100644 index 000000000..838038ab9 --- /dev/null +++ b/extensions/mentions/js/src/forum/components/GroupMentionedNotification.js @@ -0,0 +1,25 @@ +import app from 'flarum/forum/app'; +import Notification from 'flarum/forum/components/Notification'; +import { truncate } from 'flarum/common/utils/string'; + +export default class GroupMentionedNotification extends Notification { + icon() { + return 'fas fa-at'; + } + + href() { + const post = this.attrs.notification.subject(); + + return app.route.discussion(post.discussion(), post.number()); + } + + content() { + const user = this.attrs.notification.fromUser(); + + return app.translator.trans('flarum-mentions.forum.notifications.group_mentioned_text', { user }); + } + + excerpt() { + return truncate(this.attrs.notification.subject().contentPlain(), 200); + } +} diff --git a/extensions/mentions/js/src/forum/index.js b/extensions/mentions/js/src/forum/index.js index ab5392f46..467e98e19 100644 --- a/extensions/mentions/js/src/forum/index.js +++ b/extensions/mentions/js/src/forum/index.js @@ -10,11 +10,16 @@ import addPostQuoteButton from './addPostQuoteButton'; import addComposerAutocomplete from './addComposerAutocomplete'; import PostMentionedNotification from './components/PostMentionedNotification'; import UserMentionedNotification from './components/UserMentionedNotification'; +import GroupMentionedNotification from './components/GroupMentionedNotification'; import UserPage from 'flarum/forum/components/UserPage'; import LinkButton from 'flarum/common/components/LinkButton'; import MentionsUserPage from './components/MentionsUserPage'; +import User from 'flarum/common/models/User'; +import Model from 'flarum/common/Model'; app.initializers.add('flarum-mentions', function () { + User.prototype.canMentionGroups = Model.attribute('canMentionGroups'); + // For every mention of a post inside a post's content, set up a hover handler // that shows a preview of the mentioned post. addPostMentionPreviews(); @@ -36,6 +41,7 @@ app.initializers.add('flarum-mentions', function () { app.notificationComponents.postMentioned = PostMentionedNotification; app.notificationComponents.userMentioned = UserMentionedNotification; + app.notificationComponents.groupMentioned = GroupMentionedNotification; // Add notification preferences. extend(NotificationGrid.prototype, 'notificationTypes', function (items) { @@ -50,6 +56,12 @@ app.initializers.add('flarum-mentions', function () { icon: 'fas fa-at', label: app.translator.trans('flarum-mentions.forum.settings.notify_user_mentioned_label'), }); + + items.add('groupMentioned', { + name: 'groupMentioned', + icon: 'fas fa-at', + label: app.translator.trans('flarum-mentions.forum.settings.notify_group_mentioned_label'), + }); }); // Add mentions tab in user profile diff --git a/extensions/mentions/js/src/forum/utils/getMentionText.js b/extensions/mentions/js/src/forum/utils/getMentionText.js index 99e92af30..6a99ee38e 100644 --- a/extensions/mentions/js/src/forum/utils/getMentionText.js +++ b/extensions/mentions/js/src/forum/utils/getMentionText.js @@ -1,7 +1,7 @@ import getCleanDisplayName, { shouldUseOldFormat } from './getCleanDisplayName'; /** - * Fetches the mention text for a specified user (and optionally a post ID for replies). + * Fetches the mention text for a specified user (and optionally a post ID for replies, or group). * * Automatically determines which mention syntax to be used based on the option in the * admin dashboard. Also performs display name clean-up automatically. @@ -17,9 +17,13 @@ import getCleanDisplayName, { shouldUseOldFormat } from './getCleanDisplayName'; * @example Using old syntax * // '@username' * getMentionText(User) // User's username is 'username' + * + * @example Group mention + * // '@"Mods"#g4' + * getMentionText(undefined, undefined, group) // Group display name is 'Mods', group ID is 4 */ -export default function getMentionText(user, postId) { - if (postId === undefined) { +export default function getMentionText(user, postId, group) { + if (user !== undefined && postId === undefined) { if (shouldUseOldFormat()) { // Plain @username const cleanText = getCleanDisplayName(user, false); @@ -28,9 +32,14 @@ export default function getMentionText(user, postId) { // @"Display name"#UserID const cleanText = getCleanDisplayName(user); return `@"${cleanText}"#${user.id()}`; - } else { + } else if (user !== undefined && postId !== undefined) { // @"Display name"#pPostID const cleanText = getCleanDisplayName(user); return `@"${cleanText}"#p${postId}`; + } else if (group !== undefined) { + // @"Name Plural"#gGroupID + return `@"${group.namePlural()}"#g${group.id()}`; + } else { + throw 'No parameters were passed'; } } diff --git a/extensions/mentions/js/src/forum/utils/textFormatter.js b/extensions/mentions/js/src/forum/utils/textFormatter.js index 9c6c26c47..415d99907 100644 --- a/extensions/mentions/js/src/forum/utils/textFormatter.js +++ b/extensions/mentions/js/src/forum/utils/textFormatter.js @@ -31,3 +31,19 @@ export function filterPostMentions(tag) { return true; } } + +export function filterGroupMentions(tag) { + if (app.session?.user?.canMentionGroups()) { + const group = app.store.getById('groups', tag.getAttribute('id')); + + if (group) { + tag.setAttribute('groupname', extractText(group.namePlural())); + tag.setAttribute('icon', group.icon()); + tag.setAttribute('color', group.color()); + + return true; + } + } + + tag.invalidate(); +} diff --git a/extensions/mentions/less/forum.less b/extensions/mentions/less/forum.less index e8e211772..0932c60df 100644 --- a/extensions/mentions/less/forum.less +++ b/extensions/mentions/less/forum.less @@ -1,4 +1,4 @@ -.PostMention, .UserMention { +.PostMention, .UserMention, .GroupMention { background: @control-bg; color: @control-color; border-radius: @border-radius; @@ -14,7 +14,7 @@ color: @link-color; } } -.UserMention, .PostMention { +.UserMention, .PostMention, .GroupMention { &--deleted { opacity: 0.8; filter: grayscale(1); @@ -97,6 +97,21 @@ position: absolute; .Button--color(@tooltip-color, @tooltip-bg); } +.GroupMention { + color: @body-bg; + + .icon { + margin-left: 5px; + } + + &:hover, + &:active { + color: @body-bg; + } +} +.MentionsDropdown .Badge { + box-shadow: none; +} @media @phone { .MentionsDropdown { diff --git a/extensions/mentions/locale/en.yml b/extensions/mentions/locale/en.yml index 962baf633..0a1ea3fba 100644 --- a/extensions/mentions/locale/en.yml +++ b/extensions/mentions/locale/en.yml @@ -7,6 +7,9 @@ flarum-mentions: # Translations in this namespace are used by the admin interface. admin: + # These translations are used in the mentions permissions + permissions: + mention_groups_label: Mention groups # These translations are used in the mentions Settings page. settings: allow_username_format_label: Allow username mention format (@Username) @@ -19,7 +22,7 @@ flarum-mentions: # These translations are used by the composer (reply autocompletion function). composer: - mention_tooltip: Mention a user or post + mention_tooltip: Mention a user, group or post reply_to_post_text: "Reply to #{number}" # These translations are used by the Notifications dropdown, a.k.a. "the bell". @@ -27,6 +30,7 @@ flarum-mentions: others_text: => core.ref.some_others post_mentioned_text: "{username} replied to your post" # Can be pluralized to agree with the number of users! user_mentioned_text: "{username} mentioned you" + group_mentioned_text: "{username} mentioned a group you're a member of" # These translations are displayed beneath individual posts. post: @@ -41,6 +45,7 @@ flarum-mentions: settings: notify_post_mentioned_label: Someone replies to one of my posts notify_user_mentioned_label: Someone mentions me in a post + notify_group_mentioned_label: Someone mentions a group I'm a member of in a post # These translations are used in the user profile page and profile popup. user: @@ -50,6 +55,9 @@ flarum-mentions: post_mention: deleted_text: "[unknown]" + group_mention: + deleted_text: "[unknown group]" + # Translations in this namespace are used in emails sent by the forum. email: @@ -80,4 +88,16 @@ flarum-mentions: --- {content} + # These translations are used in emails sent when a group is mentioned + group_mentioned: + subject: "{mentioner_display_name} mentioned a group you're a member of in {title}" + body: | + Hey {recipient_display_name}! + {mentioner_display_name} mentioned a group you're a member of in {title}. + + {url} + + --- + + {content} diff --git a/extensions/mentions/migrations/2022_10_21_000000_create_post_mentions_group_table.php b/extensions/mentions/migrations/2022_10_21_000000_create_post_mentions_group_table.php new file mode 100644 index 000000000..7ef19646b --- /dev/null +++ b/extensions/mentions/migrations/2022_10_21_000000_create_post_mentions_group_table.php @@ -0,0 +1,29 @@ + function (Builder $schema) { + $schema->create('post_mentions_group', function (Blueprint $table) { + $table->integer('post_id')->unsigned(); + $table->integer('mentions_group_id')->unsigned(); + $table->dateTime('created_at')->useCurrent()->nullable(); + $table->primary(['post_id', 'mentions_group_id']); + + $table->foreign('post_id')->references('id')->on('posts')->onDelete('cascade'); + $table->foreign('mentions_group_id')->references('id')->on('groups')->onDelete('cascade'); + }); + }, + + 'down' => function (Builder $schema) { + $schema->drop('post_mentions_group'); + } +]; diff --git a/extensions/mentions/src/ConfigureMentions.php b/extensions/mentions/src/ConfigureMentions.php index a85727707..d57904f5e 100644 --- a/extensions/mentions/src/ConfigureMentions.php +++ b/extensions/mentions/src/ConfigureMentions.php @@ -9,6 +9,7 @@ namespace Flarum\Mentions; +use Flarum\Group\Group; use Flarum\Http\UrlGenerator; use Flarum\Post\CommentPost; use Flarum\Settings\SettingsRepositoryInterface; @@ -34,6 +35,7 @@ class ConfigureMentions { $this->configureUserMentions($config); $this->configurePostMentions($config); + $this->configureGroupMentions($config); } private function configureUserMentions(Configurator $config) @@ -136,4 +138,49 @@ class ConfigureMentions return true; } } + + private function configureGroupMentions(Configurator $config) + { + $tagName = 'GROUPMENTION'; + + $tag = $config->tags->add($tagName); + $tag->attributes->add('groupname'); + $tag->attributes->add('icon'); + $tag->attributes->add('color'); + $tag->attributes->add('id')->filterChain->append('#uint'); + + $tag->template = ' + + + @ + + + @ + + '; + $tag->filterChain->prepend([static::class, 'addGroupId']) + ->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterGroupMentions(tag); }'); + + $config->Preg->match('/\B@["|“](?((?!"#[a-z]{0,3}[0-9]+).)+)["|”]#g(?[0-9]+)\b/', $tagName); + } + + /** + * @param $tag + * @return bool + */ + public static function addGroupId($tag) + { + $group = Group::find($tag->getAttribute('id')); + + if (isset($group) && ! in_array($group->id, [Group::GUEST_ID, Group::MEMBER_ID])) { + $tag->setAttribute('id', $group->id); + $tag->setAttribute('groupname', $group->name_plural); + $tag->setAttribute('icon', $group->icon ?? 'fas fa-at'); + $tag->setAttribute('color', $group->color); + + return true; + } + + $tag->invalidate(); + } } diff --git a/extensions/mentions/src/FilterVisiblePosts.php b/extensions/mentions/src/FilterVisiblePosts.php index 256025ec7..47aec39d2 100755 --- a/extensions/mentions/src/FilterVisiblePosts.php +++ b/extensions/mentions/src/FilterVisiblePosts.php @@ -54,8 +54,8 @@ class FilterVisiblePosts || $controller instanceof Controller\CreatePostController || $controller instanceof Controller\UpdatePostController) { $relations = [ - 'mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionedBy', - 'mentionedBy.mentionsPosts', 'mentionedBy.mentionsPosts.user', 'mentionedBy.mentionsUsers' + 'mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionedBy', 'mentionsGroups', + 'mentionedBy.mentionsPosts', 'mentionedBy.mentionsPosts.user', 'mentionedBy.mentionsUsers', 'mentionedBy.mentionsGroups.group' ]; $posts = [$data]; diff --git a/extensions/mentions/src/Formatter/CheckPermissions.php b/extensions/mentions/src/Formatter/CheckPermissions.php new file mode 100644 index 000000000..c6a899b45 --- /dev/null +++ b/extensions/mentions/src/Formatter/CheckPermissions.php @@ -0,0 +1,26 @@ +cannot('mentionGroups')) { + $parser->disableTag('GROUPMENTION'); + } + + return $text; + } +} diff --git a/extensions/mentions/src/Formatter/FormatGroupMentions.php b/extensions/mentions/src/Formatter/FormatGroupMentions.php new file mode 100644 index 000000000..713166cc1 --- /dev/null +++ b/extensions/mentions/src/Formatter/FormatGroupMentions.php @@ -0,0 +1,59 @@ +translator = $translator; + } + + /** + * Configure rendering for group mentions. + * + * @param \s9e\TextFormatter\Renderer $renderer + * @param mixed $context + * @param string $xml + * @return string + */ + public function __invoke(Renderer $renderer, $context, string $xml): string + { + return Utils::replaceAttributes($xml, 'GROUPMENTION', function ($attributes) use ($context) { + $group = (($context && isset($context->getRelations()['mentionsGroups'])) || $context instanceof Post) + ? $context->mentionsGroups->find($attributes['id']) + : Group::find($attributes['id']); + + if ($group) { + $attributes['groupname'] = $group->name_plural; + $attributes['icon'] = $group->icon ?? 'fas fa-at'; + $attributes['color'] = $group->color; + $attributes['deleted'] = false; + } else { + $attributes['groupname'] = $this->translator->trans('flarum-mentions.forum.group_mention.deleted_text'); + $attributes['icon'] = ''; + $attributes['deleted'] = true; + } + + return $attributes; + }); + } +} diff --git a/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenInvisible.php b/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenInvisible.php index e130b1477..fafe2a850 100755 --- a/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenInvisible.php +++ b/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenInvisible.php @@ -40,5 +40,8 @@ class UpdateMentionsMetadataWhenInvisible // Remove post mentions $event->post->mentionsPosts()->sync([]); + + // Remove group mentions + $event->post->mentionsGroups()->sync([]); } } diff --git a/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenVisible.php b/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenVisible.php index 47a94a610..231f6cfd7 100755 --- a/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenVisible.php +++ b/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenVisible.php @@ -9,6 +9,7 @@ namespace Flarum\Mentions\Listener; +use Flarum\Mentions\Notification\GroupMentionedBlueprint; use Flarum\Mentions\Notification\PostMentionedBlueprint; use Flarum\Mentions\Notification\UserMentionedBlueprint; use Flarum\Notification\NotificationSyncer; @@ -50,6 +51,11 @@ class UpdateMentionsMetadataWhenVisible $event->post, Utils::getAttributeValues($content, 'POSTMENTION', 'id') ); + + $this->syncGroupMentions( + $event->post, + Utils::getAttributeValues($content, 'GROUPMENTION', 'id') + ); } protected function syncUserMentions(Post $post, array $mentioned) @@ -84,4 +90,21 @@ class UpdateMentionsMetadataWhenVisible $this->notifications->sync(new PostMentionedBlueprint($post, $reply), [$post->user]); } } + + protected function syncGroupMentions(Post $post, array $mentioned) + { + $post->mentionsGroups()->sync($mentioned); + $post->unsetRelation('mentionsGroups'); + + $users = User::whereHas('groups', function ($query) use ($mentioned) { + $query->whereIn('id', $mentioned); + }) + ->get() + ->filter(function (User $user) use ($post) { + return $post->isVisibleTo($user) && $user->id !== $post->user_id; + }) + ->all(); + + $this->notifications->sync(new GroupMentionedBlueprint($post), $users); + } } diff --git a/extensions/mentions/src/Notification/GroupMentionedBlueprint.php b/extensions/mentions/src/Notification/GroupMentionedBlueprint.php new file mode 100644 index 000000000..1842d0e24 --- /dev/null +++ b/extensions/mentions/src/Notification/GroupMentionedBlueprint.php @@ -0,0 +1,89 @@ +post = $post; + } + + /** + * {@inheritdoc} + */ + public function getSubject() + { + return $this->post; + } + + /** + * {@inheritdoc} + */ + public function getFromUser() + { + return $this->post->user; + } + + /** + * {@inheritdoc} + */ + public function getData() + { + } + + /** + * {@inheritdoc} + */ + public function getEmailView() + { + return ['text' => 'flarum-mentions::emails.groupMentioned']; + } + + /** + * {@inheritdoc} + */ + public function getEmailSubject(TranslatorInterface $translator) + { + return $translator->trans('flarum-mentions.email.group_mentioned.subject', [ + '{mentioner_display_name}' => $this->post->user->display_name, + '{title}' => $this->post->discussion->title + ]); + } + + /** + * {@inheritdoc} + */ + public static function getType() + { + return 'groupMentioned'; + } + + /** + * {@inheritdoc} + */ + public static function getSubjectModel() + { + return Post::class; + } +} diff --git a/extensions/mentions/tests/integration/api/GroupMentionsTest.php b/extensions/mentions/tests/integration/api/GroupMentionsTest.php new file mode 100644 index 000000000..78ea3d50c --- /dev/null +++ b/extensions/mentions/tests/integration/api/GroupMentionsTest.php @@ -0,0 +1,420 @@ +extension('flarum-mentions'); + + $this->prepareDatabase([ + 'users' => [ + ['id' => 3, 'username' => 'potato', 'email' => 'potato@machine.local', 'is_email_confirmed' => 1], + ['id' => 4, 'username' => 'toby', 'email' => 'toby@machine.local', 'is_email_confirmed' => 1], + ['id' => 5, 'username' => 'bad_user', 'email' => 'bad_user@machine.local', 'is_email_confirmed' => 1], + ], + 'discussions' => [ + ['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 3, 'first_post_id' => 4, 'comment_count' => 2], + ], + 'posts' => [ + ['id' => 4, 'number' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '

One of the @"Mods"#g4 will look at this

'], + ['id' => 6, 'number' => 3, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '

@"OldGroupName"#g100

'], + ['id' => 7, 'number' => 4, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '

@"OldGroupName"#g11

'], + ], + 'post_mentions_group' => [ + ['post_id' => 4, 'mentions_group_id' => 4], + ['post_id' => 7, 'mentions_group_id' => 11], + ], + 'group_permission' => [ + ['group_id' => Group::MEMBER_ID, 'permission' => 'postWithoutThrottle'], + ], + 'groups' => [ + [ + 'id' => 10, + 'name_singular' => 'Hidden', + 'name_plural' => 'Ninjas', + 'color' => null, + 'icon' => 'fas fa-wrench', + 'is_hidden' => 1 + ], + [ + 'id' => 11, + 'name_singular' => 'Fresh Name', + 'name_plural' => 'Fresh Name', + 'color' => '#ccc', + 'icon' => 'fas fa-users', + 'is_hidden' => 0 + ] + ] + ]); + } + + /** + * @test + */ + public function rendering_a_valid_group_mention_works() + { + $response = $this->send( + $this->request('GET', '/api/posts/4') + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('

One of the @Mods will look at this

', $response['data']['attributes']['contentHtml']); + $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsGroups->find(4)); + } + + /** + * @test + */ + public function mentioning_an_invalid_group_doesnt_work() + { + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"InvalidGroup"#g99', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ] + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('@"InvalidGroup"#g99', $response['data']['attributes']['content']); + $this->assertStringNotContainsString('GroupMention', $response['data']['attributes']['contentHtml']); + $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups); + } + + /** + * @test + */ + public function deleted_group_mentions_render_with_deleted_label() + { + $deleted_text = $this->app()->getContainer()->make('translator')->trans('flarum-mentions.forum.group_mention.deleted_text'); + + $response = $this->send( + $this->request('GET', '/api/posts/6', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString("@$deleted_text", $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('GroupMention--deleted', $response['data']['attributes']['contentHtml']); + $this->assertStringNotContainsString('@OldGroupName', $response['data']['attributes']['contentHtml']); + $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups); + } + + /** + * @test + */ + public function group_mentions_render_with_fresh_data() + { + $response = $this->send( + $this->request('GET', '/api/posts/7', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('@Fresh Name', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']); + $this->assertStringNotContainsString('@OldGroupName', $response['data']['attributes']['contentHtml']); + $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsGroups->find(11)); + } + + /** + * @test + */ + public function mentioning_a_group_as_an_admin_user_works() + { + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"Mods"#g4', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ] + ] + ] + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('@Mods', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('fas fa-bolt', $response['data']['attributes']['contentHtml']); + $this->assertEquals('@"Mods"#g4', $response['data']['attributes']['content']); + $this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']); + $this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsGroups); + } + + /** + * @test + */ + public function mentioning_multiple_groups_as_an_admin_user_works() + { + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"Admins"#g1 @"Mods"#g4', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ] + ] + ] + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('@Admins', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('@Mods', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('fas fa-wrench', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('fas fa-bolt', $response['data']['attributes']['contentHtml']); + $this->assertEquals('@"Admins"#g1 @"Mods"#g4', $response['data']['attributes']['content']); + $this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']); + $this->assertCount(2, CommentPost::find($response['data']['id'])->mentionsGroups); + } + + /** + * @test + */ + public function mentioning_a_virtual_group_as_an_admin_user_does_not_work() + { + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"Members"#g3 @"Guests"#g2', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ] + ] + ] + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringNotContainsString('@Members', $response['data']['attributes']['contentHtml']); + $this->assertStringNotContainsString('@Guests', $response['data']['attributes']['contentHtml']); + $this->assertEquals('@"Members"#g3 @"Guests"#g2', $response['data']['attributes']['content']); + $this->assertStringNotContainsString('GroupMention', $response['data']['attributes']['contentHtml']); + $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups); + } + + /** + * @test + */ + public function regular_user_does_not_have_group_mention_permission_by_default() + { + $this->database(); + $this->assertFalse(User::find(3)->can('mentionGroups')); + } + + /** + * @test + */ + public function regular_user_does_have_group_mention_permission_when_added() + { + $this->prepareDatabase([ + 'group_permission' => [ + ['group_id' => Group::MEMBER_ID, 'permission' => 'mentionGroups'], + ] + ]); + + $this->database(); + $this->assertTrue(User::find(3)->can('mentionGroups')); + } + + /** + * @test + */ + public function user_without_permission_cannot_mention_groups() + { + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 3, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"Mods"#g4', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringNotContainsString('@Mods', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('@"Mods"#g4', $response['data']['attributes']['content']); + $this->assertStringNotContainsString('GroupMention', $response['data']['attributes']['contentHtml']); + $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups); + } + + /** + * @test + */ + public function user_with_permission_can_mention_groups() + { + $this->prepareDatabase([ + 'group_permission' => [ + ['group_id' => Group::MEMBER_ID, 'permission' => 'mentionGroups'], + ] + ]); + + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 3, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"Mods"#g4', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('@Mods', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('@"Mods"#g4', $response['data']['attributes']['content']); + $this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']); + $this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsGroups); + } + + /** + * @test + */ + public function user_with_permission_cannot_mention_hidden_groups() + { + $this->prepareDatabase([ + 'group_permission' => [ + ['group_id' => Group::MEMBER_ID, 'permission' => 'mentionGroups'], + ] + ]); + + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 3, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"Ninjas"#g10', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringNotContainsString('@Ninjas', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('@"Ninjas"#g10', $response['data']['attributes']['content']); + $this->assertStringNotContainsString('GroupMention', $response['data']['attributes']['contentHtml']); + $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups); + } + + /** + * @test + */ + public function editing_a_post_that_has_a_mention_works() + { + $response = $this->send( + $this->request('PATCH', '/api/posts/4', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => 'New content with @"Mods"#g4 mention', + ], + ], + ], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('@Mods', $response['data']['attributes']['contentHtml']); + $this->assertEquals('New content with @"Mods"#g4 mention', $response['data']['attributes']['content']); + $this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']); + $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsGroups->find(4)); + } +} diff --git a/extensions/mentions/views/emails/groupMentioned.blade.php b/extensions/mentions/views/emails/groupMentioned.blade.php new file mode 100644 index 000000000..52b6f0458 --- /dev/null +++ b/extensions/mentions/views/emails/groupMentioned.blade.php @@ -0,0 +1,7 @@ +{!! $translator->trans('flarum-mentions.email.group_mentioned.body', [ +'{recipient_display_name}' => $user->display_name, +'{mentioner_display_name}' => $blueprint->post->user->display_name, +'{title}' => $blueprint->post->discussion->title, +'{url}' => $url->to('forum')->route('discussion', ['id' => $blueprint->post->discussion_id, 'near' => $blueprint->post->number]), +'{content}' => $blueprint->post->content +]) !!} diff --git a/framework/core/src/Extend/Formatter.php b/framework/core/src/Extend/Formatter.php index 0eb79a9ff..75f7f5418 100644 --- a/framework/core/src/Extend/Formatter.php +++ b/framework/core/src/Extend/Formatter.php @@ -52,6 +52,7 @@ class Formatter implements ExtenderInterface, LifecycleInterface * - \s9e\TextFormatter\Parser $parser * - mixed $context * - string $text: The text to be parsed. + * - \Flarum\User\User|null $actor. This argument MUST either be nullable, or omitted entirely. * * The callback should return: * - string $text: The text to be parsed. diff --git a/framework/core/src/Formatter/Formatter.php b/framework/core/src/Formatter/Formatter.php index 46958d0e9..2db336cd2 100644 --- a/framework/core/src/Formatter/Formatter.php +++ b/framework/core/src/Formatter/Formatter.php @@ -9,6 +9,7 @@ namespace Flarum\Formatter; +use Flarum\User\User; use Illuminate\Contracts\Cache\Repository; use Psr\Http\Message\ServerRequestInterface; use s9e\TextFormatter\Configurator; @@ -83,14 +84,15 @@ class Formatter * * @param string $text * @param mixed $context + * @param User|null $user * @return string */ - public function parse($text, $context = null) + public function parse($text, $context = null, User $user = null) { $parser = $this->getParser($context); foreach ($this->parsingCallbacks as $callback) { - $text = $callback($parser, $context, $text); + $text = $callback($parser, $context, $text, $user); } return $parser->parse($text); diff --git a/framework/core/src/Post/Command/PostReplyHandler.php b/framework/core/src/Post/Command/PostReplyHandler.php index 96de698d0..b0bfb8877 100644 --- a/framework/core/src/Post/Command/PostReplyHandler.php +++ b/framework/core/src/Post/Command/PostReplyHandler.php @@ -85,7 +85,8 @@ class PostReplyHandler $discussion->id, Arr::get($command->data, 'attributes.content'), $actor->id, - $command->ipAddress + $command->ipAddress, + $command->actor, ); if ($actor->isAdmin() && ($time = Arr::get($command->data, 'attributes.createdAt'))) { diff --git a/framework/core/src/Post/CommentPost.php b/framework/core/src/Post/CommentPost.php index 6e89e79ea..7455abc12 100644 --- a/framework/core/src/Post/CommentPost.php +++ b/framework/core/src/Post/CommentPost.php @@ -44,9 +44,10 @@ class CommentPost extends Post * @param string $content * @param int $userId * @param string $ipAddress + * @param User|null $actor * @return static */ - public static function reply($discussionId, $content, $userId, $ipAddress) + public static function reply($discussionId, $content, $userId, $ipAddress, User $actor = null) { $post = new static; @@ -57,7 +58,7 @@ class CommentPost extends Post $post->ip_address = $ipAddress; // Set content last, as the parsing may rely on other post attributes. - $post->content = $content; + $post->setContentAttribute($content, $actor); $post->raise(new Posted($post)); @@ -74,7 +75,7 @@ class CommentPost extends Post public function revise($content, User $actor) { if ($this->content !== $content) { - $this->content = $content; + $this->setContentAttribute($content, $actor); $this->edited_at = Carbon::now(); $this->edited_user_id = $actor->id; @@ -145,10 +146,11 @@ class CommentPost extends Post * Parse the content before it is saved to the database. * * @param string $value + * @param User $actor */ - public function setContentAttribute($value) + public function setContentAttribute($value, User $actor = null) { - $this->attributes['content'] = $value ? static::$formatter->parse($value, $this) : null; + $this->attributes['content'] = $value ? static::$formatter->parse($value, $this, $actor ?? $this->user) : null; } /**