[mentions] feat: group mentions (#3658)

* wip: group mentions

* Apply fixes from StyleCI

* chore: format

* group mention autocomplete

* chore: format

* remove console.log

* implement notifications

* prevent guest and member groups from being mentioned

* Update extensions/mentions/less/forum.less

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* rename displayname to groupname

* Update extensions/mentions/src/Formatter/FormatGroupMentions.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* remove redundant unparse

* simplify migrations

* add group deleted translation

* Apply fixes from StyleCI

* handle everything falsy

* Include icon in group mention preview

* remove box-shadow from autocomplete group results

* Add color to preview

* chore: format

* Remove box shadow from group autocomplete results

* Update extensions/mentions/migrations/2022_10_21_000000_create_post_mentions_groups_table.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* remove unneeded migration

* prevent former group icon from beingdisplayed

* add group searcher with permissions

* Apply fixes from StyleCI

* Search groups based on canSearchGroups permission

* Don't include virtual groups in results

* Add search groups translation

* Revert "remove unneeded migration"

This reverts commit 9347665baa1820ed0875c6b2dd22b14fbc2768e7.

* Revert "Update extensions/mentions/migrations/2022_10_21_000000_create_post_mentions_groups_table.php"

This reverts commit 8406d51df276d8f14917bfdc45a86e6b847784b7.

* add searchGroups permission to tests

* Apply fixes from StyleCI

* Add default searchGroups permission

* Apply fixes from StyleCI

* Update extensions/mentions/js/src/forum/addComposerAutocomplete.js

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update extensions/mentions/migrations/2022_10_21_000000_create_post_mentions_groups_table.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* remove unneeded migration, correct table table

* correct table name in down migration

* Remove group searcher

* Apply fixes from StyleCI

* Remove group searching from composer autocomplete

* Add mentionGroups permission

* Apply fixes from StyleCI

* prevent post preview from rendering a group mention when user does not have permission

* remove test changes

* wip: expose ServerRequestInterface to textformatter parse()

* Apply fixes from StyleCI

* Set post content properly

* php 7.x compatibility

* begin adding groupmention tests

* Apply fixes from StyleCI

* test virtual groups don't mention

* Apply fixes from StyleCI

* Update framework/core/tests/integration/api/groups/ListTest.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update framework/core/tests/integration/api/groups/ListTest.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update framework/core/tests/integration/api/groups/ListTest.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update framework/core/tests/integration/api/groups/ListTest.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update framework/core/tests/integration/api/groups/ListTest.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update extensions/mentions/extend.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update extensions/mentions/extend.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* requested changes

* Update framework/core/tests/integration/api/groups/ListTest.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update framework/core/tests/integration/api/groups/ListTest.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update framework/core/src/Search/SearchServiceProvider.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update framework/core/src/Extend/Formatter.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* remove default permission migration

* try using datetime column instead of timestamp

* Apply fixes from StyleCI

* chore: remove commented code

* add tests

* Apply fixes from StyleCI

* Pass actor to parser instead of ServerRequest

* Allow for  to be null

* Update framework/core/src/Extend/Formatter.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* pass actor instead of request

* Apply fixes from StyleCI

* actor instead of request

* remove serverrequest

* Apply fixes from StyleCI

* remove dupe actor

* Update extensions/mentions/src/Formatter/CheckPermissions.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* fix type in comment

* group does not have the relation, post does

* test: invalid, deleted, fresh data mentions

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

* Apply fixes from StyleCI

* fix: group mentions don't work when editing posts

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
Co-authored-by: StyleCI Bot <bot@styleci.io>
Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>
This commit is contained in:
Ian Morland 2022-11-05 19:29:01 +00:00 committed by GitHub
parent cdc76567d4
commit 827e905f8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 915 additions and 34 deletions

View File

@ -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');
})
];

View File

@ -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'
);
});

View File

@ -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 (
<button
className={'PostPreview ' + className}
onclick={() => applySuggestion(replacement)}
onmouseenter={function () {
dropdown.setIndex($(this).parent().index());
}}
>
<span className="PostPreview-content">
<Badge class={`Avatar Badge Badge--group--${group.id()} Badge-icon `} color={group.color()} type="group" icon={group.icon()} />
<span className="username">{groupName}</span>
</span>
</button>
);
};
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

View File

@ -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,

View File

@ -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);
}
}

View File

@ -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

View File

@ -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 <caption>Using old syntax</caption>
* // '@username'
* getMentionText(User) // User's username is 'username'
*
* @example <caption>Group mention</caption>
* // '@"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';
}
}

View File

@ -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();
}

View File

@ -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 {

View File

@ -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}

View File

@ -0,0 +1,29 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => 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');
}
];

View File

@ -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 = '
<xsl:choose>
<xsl:when test="@deleted != 1">
<span class="GroupMention" style="background: {@color}">@<xsl:value-of select="@groupname"/><i class="icon {@icon}"></i></span>
</xsl:when>
<xsl:otherwise>
<span class="GroupMention GroupMention--deleted" style="background: {@color}">@<xsl:value-of select="@groupname"/><i class="icon {@icon}"></i></span>
</xsl:otherwise>
</xsl:choose>';
$tag->filterChain->prepend([static::class, 'addGroupId'])
->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterGroupMentions(tag); }');
$config->Preg->match('/\B@["|“](?<groupname>((?!"#[a-z]{0,3}[0-9]+).)+)["|”]#g(?<id>[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();
}
}

View File

@ -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];

View File

@ -0,0 +1,26 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Mentions\Formatter;
use Flarum\User\User;
use s9e\TextFormatter\Parser;
class CheckPermissions
{
public function __invoke(Parser $parser, $content, string $text, ?User $actor): string
{
// Check user has `mentionGroups` permission, if not, remove the `GROUPMENTION` tag from the parser.
if ($actor && $actor->cannot('mentionGroups')) {
$parser->disableTag('GROUPMENTION');
}
return $text;
}
}

View File

@ -0,0 +1,59 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Mentions\Formatter;
use Flarum\Group\Group;
use Flarum\Post\Post;
use s9e\TextFormatter\Renderer;
use s9e\TextFormatter\Utils;
use Symfony\Contracts\Translation\TranslatorInterface;
class FormatGroupMentions
{
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(TranslatorInterface $translator)
{
$this->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;
});
}
}

View File

@ -40,5 +40,8 @@ class UpdateMentionsMetadataWhenInvisible
// Remove post mentions
$event->post->mentionsPosts()->sync([]);
// Remove group mentions
$event->post->mentionsGroups()->sync([]);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,89 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Mentions\Notification;
use Flarum\Notification\Blueprint\BlueprintInterface;
use Flarum\Notification\MailableInterface;
use Flarum\Post\Post;
use Symfony\Contracts\Translation\TranslatorInterface;
class GroupMentionedBlueprint implements BlueprintInterface, MailableInterface
{
/**
* @var Post
*/
public $post;
/**
* @param Post $post
*/
public function __construct(Post $post)
{
$this->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;
}
}

View File

@ -0,0 +1,420 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Mentions\Tests\integration\api;
use Carbon\Carbon;
use Flarum\Group\Group;
use Flarum\Post\CommentPost;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
class GroupMentionsTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @inheritDoc
*/
protected function setUp(): void
{
parent::setUp();
$this->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' => '<r><p>One of the <GROUPMENTION color="#80349E" groupname="Mods" icon="fas fa-bolt" id="4">@"Mods"#g4</GROUPMENTION> will look at this</p></r>'],
['id' => 6, 'number' => 3, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><p><GROUPMENTION color="#80349E" groupname="OldGroupName" icon="fas fa-circle" id="100">@"OldGroupName"#g100</GROUPMENTION></p></r>'],
['id' => 7, 'number' => 4, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><p><GROUPMENTION color="#000" groupname="OldGroupName" icon="fas fa-circle" id="11">@"OldGroupName"#g11</GROUPMENTION></p></r>'],
],
'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('<p>One of the <span style="background:#80349E" class="GroupMention">@Mods<i class="icon fas fa-bolt"></i></span> will look at this</p>', $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));
}
}

View File

@ -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
]) !!}

View File

@ -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.

View File

@ -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);

View File

@ -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'))) {

View File

@ -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;
}
/**