diff --git a/extensions/mentions/extend.php b/extensions/mentions/extend.php index 345e47907..5f52d4798 100644 --- a/extensions/mentions/extend.php +++ b/extensions/mentions/extend.php @@ -31,10 +31,15 @@ return [ ->js(__DIR__.'/js/dist/forum.js') ->css(__DIR__.'/less/forum.less'), + (new Extend\Frontend('admin')) + ->js(__DIR__.'/js/dist/admin.js'), + (new Extend\Formatter) ->configure(ConfigureMentions::class) ->render(Formatter\FormatPostMentions::class) - ->render(Formatter\FormatUserMentions::class), + ->render(Formatter\FormatUserMentions::class) + ->unparse(Formatter\UnparsePostMentions::class) + ->unparse(Formatter\UnparseUserMentions::class), (new Extend\Model(Post::class)) ->belongsToMany('mentionedBy', Post::class, 'post_mentions_post', 'mentions_post_id', 'post_id') @@ -70,6 +75,9 @@ return [ (new Extend\ApiController(Controller\AbstractSerializeController::class)) ->prepareDataForSerialization(FilterVisiblePosts::class), + (new Extend\Settings) + ->serializeToForum('allowUsernameMentionFormat', 'flarum-mentions.allow_username_format', 'boolval'), + (new Extend\Event()) ->listen(Posted::class, Listener\UpdateMentionsMetadataWhenVisible::class) ->listen(Restored::class, Listener\UpdateMentionsMetadataWhenVisible::class) diff --git a/extensions/mentions/js/admin.js b/extensions/mentions/js/admin.js new file mode 100644 index 000000000..3e69ff3b9 --- /dev/null +++ b/extensions/mentions/js/admin.js @@ -0,0 +1 @@ +export * from './src/admin'; diff --git a/extensions/mentions/js/src/admin/index.js b/extensions/mentions/js/src/admin/index.js new file mode 100644 index 000000000..069ae009d --- /dev/null +++ b/extensions/mentions/js/src/admin/index.js @@ -0,0 +1,10 @@ +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') + }); +}); diff --git a/extensions/mentions/js/src/forum/addComposerAutocomplete.js b/extensions/mentions/js/src/forum/addComposerAutocomplete.js index 27f4b6db0..e96298e94 100644 --- a/extensions/mentions/js/src/forum/addComposerAutocomplete.js +++ b/extensions/mentions/js/src/forum/addComposerAutocomplete.js @@ -10,6 +10,7 @@ import KeyboardNavigatable from 'flarum/utils/KeyboardNavigatable'; import { truncate } from 'flarum/utils/string'; import AutocompleteDropdown from './fragments/AutocompleteDropdown'; +import cleanDisplayName from './utils/cleanDisplayName'; export default function addComposerAutocomplete() { const $container = $('
'); @@ -35,6 +36,7 @@ export default function addComposerAutocomplete() { let relMentionStart; let absMentionStart; let typed; + let matchTyped; let searchTimeout; // We store users returned from an API here to preserve order in which they are returned @@ -74,6 +76,8 @@ export default function addComposerAutocomplete() { if (absMentionStart) { typed = lastChunk.substring(relMentionStart).toLowerCase(); + matchTyped = typed.match(/^"((?:(?!"#).)+)$/); + typed = (matchTyped && matchTyped[1]) || typed; const makeSuggestion = function(user, replacement, content, className = '') { const username = usernameHelper(user); @@ -116,7 +120,7 @@ export default function addComposerAutocomplete() { if (!userMatches(user)) return; suggestions.push( - makeSuggestion(user, '@' + user.username(), '', 'MentionsDropdown-user') + makeSuggestion(user, `@"${cleanDisplayName(user)}"#${user.id()}`, '', 'MentionsDropdown-user') ); }); } @@ -142,7 +146,7 @@ export default function addComposerAutocomplete() { .forEach(post => { const user = post.user(); suggestions.push( - makeSuggestion(user, '@' + user.username() + '#' + post.id(), [ + makeSuggestion(user, `@"${cleanDisplayName(user)}"#p${post.id()}`, [ app.translator.trans('flarum-mentions.forum.composer.reply_to_post_text', {number: post.number()}), ' — ', truncate(post.contentPlain(), 200) ], 'MentionsDropdown-post') diff --git a/extensions/mentions/js/src/forum/addPostMentionPreviews.js b/extensions/mentions/js/src/forum/addPostMentionPreviews.js index 76e4400da..5707caa86 100644 --- a/extensions/mentions/js/src/forum/addPostMentionPreviews.js +++ b/extensions/mentions/js/src/forum/addPostMentionPreviews.js @@ -14,12 +14,12 @@ export default function addPostMentionPreviews() { const parentPost = this.attrs.post; const $parentPost = this.$(); - this.$().on('click', '.UserMention, .PostMention', function (e) { + this.$().on('click', '.UserMention:not(.UserMention--deleted), .PostMention:not(.PostMention--deleted)', function (e) { m.route.set(this.getAttribute('href')); e.preventDefault(); }); - this.$('.PostMention').each(function() { + this.$('.PostMention:not(.PostMention--deleted)').each(function() { const $this = $(this); const id = $this.data('id'); let timeout; diff --git a/extensions/mentions/js/src/forum/utils/cleanDisplayName.js b/extensions/mentions/js/src/forum/utils/cleanDisplayName.js new file mode 100644 index 000000000..b32c2282c --- /dev/null +++ b/extensions/mentions/js/src/forum/utils/cleanDisplayName.js @@ -0,0 +1,3 @@ +export default function cleanDisplayName(user) { + return user.displayName().replace(/"#[a-z]{0,3}[0-9]+/, '_'); +}; diff --git a/extensions/mentions/js/src/forum/utils/reply.js b/extensions/mentions/js/src/forum/utils/reply.js index e9fc5d3a2..2355641b5 100644 --- a/extensions/mentions/js/src/forum/utils/reply.js +++ b/extensions/mentions/js/src/forum/utils/reply.js @@ -1,9 +1,10 @@ import DiscussionControls from 'flarum/utils/DiscussionControls'; import EditPostComposer from 'flarum/components/EditPostComposer'; +import cleanDisplayName from './cleanDisplayName'; function insertMention(post, composer, quote) { const user = post.user(); - const mention = '@' + (user ? user.username() : post.number()) + '#' + post.id() + ' '; + const mention = `@"${(user && cleanDisplayName(user)) || app.translator.trans('core.lib.username.deleted_text')}"#p${post.id()}`; // If the composer is empty, then assume we're starting a new reply. // In which case we don't want the user to have to confirm if they diff --git a/extensions/mentions/js/src/forum/utils/textFormatter.js b/extensions/mentions/js/src/forum/utils/textFormatter.js index 747a490a9..f1aa9aef5 100644 --- a/extensions/mentions/js/src/forum/utils/textFormatter.js +++ b/extensions/mentions/js/src/forum/utils/textFormatter.js @@ -2,14 +2,22 @@ import username from 'flarum/helpers/username'; import extractText from 'flarum/utils/extractText'; export function filterUserMentions(tag) { - const user = app.store.getBy('users', 'username', tag.getAttribute('username')); + let user; + + if (app.forum.attribute('allowUsernameMentionFormat') && tag.hasAttribute('username')) + user = app.store.getBy('users', 'username', tag.getAttribute('username')); + else if (tag.hasAttribute('id')) + user = app.store.getById('users', tag.getAttribute('id')); if (user) { tag.setAttribute('id', user.id()); + tag.setAttribute('slug', user.slug()); tag.setAttribute('displayname', extractText(username(user))); return true; } + + tag.invalidate(); } export function filterPostMentions(tag) { diff --git a/extensions/mentions/less/forum.less b/extensions/mentions/less/forum.less index ee1bcb7fc..3292dd4cf 100644 --- a/extensions/mentions/less/forum.less +++ b/extensions/mentions/less/forum.less @@ -4,6 +4,7 @@ border-radius: @border-radius; padding: 2px 5px; border: 0 !important; + font-weight: 600; blockquote & { background: @body-bg; @@ -13,6 +14,12 @@ color: @link-color; } } +.UserMention, .PostMention { + &--deleted { + opacity: 0.8; + filter: grayscale(1); + } +} .PostMention { margin: 0 3px; diff --git a/extensions/mentions/locale/en.yml b/extensions/mentions/locale/en.yml index a46a07950..962baf633 100644 --- a/extensions/mentions/locale/en.yml +++ b/extensions/mentions/locale/en.yml @@ -4,6 +4,16 @@ flarum-mentions: # UNIQUE KEYS - The following keys are used in only one location each. ## + # Translations in this namespace are used by the admin interface. + admin: + + # These translations are used in the mentions Settings page. + settings: + allow_username_format_label: Allow username mention format (@Username) + allow_username_format_text: | + The current format for user mentions is @"Display Name"#ID. + This setting allows using the old format of @Username, however it will still be converted to the new format upon saving. + # Translations in this namespace are used by the forum user interface. forum: @@ -36,6 +46,10 @@ flarum-mentions: user: mentions_link: Mentions + # These translations are used in the post mentions + post_mention: + deleted_text: "[unknown]" + # Translations in this namespace are used in emails sent by the forum. email: diff --git a/extensions/mentions/migrations/2021_04_19_000000_set_default_settings.php b/extensions/mentions/migrations/2021_04_19_000000_set_default_settings.php new file mode 100644 index 000000000..5d6cb90d2 --- /dev/null +++ b/extensions/mentions/migrations/2021_04_19_000000_set_default_settings.php @@ -0,0 +1,14 @@ + 1, +]); diff --git a/extensions/mentions/src/ConfigureMentions.php b/extensions/mentions/src/ConfigureMentions.php index f942a1a31..f1d50d46e 100644 --- a/extensions/mentions/src/ConfigureMentions.php +++ b/extensions/mentions/src/ConfigureMentions.php @@ -11,6 +11,7 @@ namespace Flarum\Mentions; use Flarum\Http\UrlGenerator; use Flarum\Post\CommentPost; +use Flarum\Settings\SettingsRepositoryInterface; use Flarum\User\User; use s9e\TextFormatter\Configurator; @@ -42,14 +43,22 @@ class ConfigureMentions $tagName = 'USERMENTION'; $tag = $config->tags->add($tagName); - $tag->attributes->add('username'); $tag->attributes->add('displayname'); $tag->attributes->add('id')->filterChain->append('#uint'); - $tag->template = '@'; + $tag->template = ' + + + @ + + + @ + + '; $tag->filterChain->prepend([static::class, 'addUserId']) ->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterUserMentions(tag); }'); + $config->Preg->match('/\B@"(?((?!"#[a-z]{0,3}[0-9]+).)+)"#(?[0-9]+)\b/', $tagName); $config->Preg->match('/\B@(?[a-z0-9_-]+)(?!#)/i', $tagName); } @@ -60,12 +69,22 @@ class ConfigureMentions */ public static function addUserId($tag) { - if ($user = User::where('username', $tag->getAttribute('username'))->first()) { + $allow_username_format = (bool) resolve(SettingsRepositoryInterface::class)->get('flarum-mentions.allow_username_format'); + + if ($tag->hasAttribute('username') && $allow_username_format) { + $user = User::where('username', $tag->getAttribute('username'))->first(); + } elseif ($tag->hasAttribute('id')) { + $user = User::find($tag->getAttribute('id')); + } + + if (isset($user)) { $tag->setAttribute('id', $user->id); $tag->setAttribute('displayname', $user->display_name); return true; } + + $tag->invalidate(); } private function configurePostMentions(Configurator $config) @@ -76,19 +95,26 @@ class ConfigureMentions $tag = $config->tags->add($tagName); - $tag->attributes->add('username'); $tag->attributes->add('displayname'); $tag->attributes->add('number')->filterChain->append('#uint'); $tag->attributes->add('discussionid')->filterChain->append('#uint'); $tag->attributes->add('id')->filterChain->append('#uint'); - $tag->template = ''; + $tag->template = ' + + + + + + + + '; $tag->filterChain ->prepend([static::class, 'addPostId']) ->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterPostMentions(tag); }'); - $config->Preg->match('/\B@(?[a-z0-9_-]+)#(?\d+)/i', $tagName); + $config->Preg->match('/\B@"(?((?!"#[a-z]{0,3}[0-9]+).)+)"#p(?[0-9]+)\b/', $tagName); } /** diff --git a/extensions/mentions/src/Formatter/FormatPostMentions.php b/extensions/mentions/src/Formatter/FormatPostMentions.php index c1a002f09..289376a7e 100644 --- a/extensions/mentions/src/Formatter/FormatPostMentions.php +++ b/extensions/mentions/src/Formatter/FormatPostMentions.php @@ -12,9 +12,20 @@ namespace Flarum\Mentions\Formatter; use Psr\Http\Message\ServerRequestInterface as Request; use s9e\TextFormatter\Renderer; use s9e\TextFormatter\Utils; +use Symfony\Contracts\Translation\TranslatorInterface; class FormatPostMentions { + /** + * @var TranslatorInterface + */ + private $translator; + + public function __construct(TranslatorInterface $translator) + { + $this->translator = $translator; + } + /** * Configure rendering for post mentions. * @@ -34,6 +45,17 @@ class FormatPostMentions $attributes['displayname'] = $post->user->display_name; } + $attributes['deleted'] = false; + + if (! $post) { + $attributes['displayname'] = $this->translator->trans('flarum-mentions.forum.post_mention.deleted_text'); + $attributes['deleted'] = true; + } + + if ($post && ! $post->user) { + $attributes['displayname'] = $this->translator->trans('core.lib.username.deleted_text'); + } + return $attributes; }); } diff --git a/extensions/mentions/src/Formatter/FormatUserMentions.php b/extensions/mentions/src/Formatter/FormatUserMentions.php index d97a2b80b..b52324195 100644 --- a/extensions/mentions/src/Formatter/FormatUserMentions.php +++ b/extensions/mentions/src/Formatter/FormatUserMentions.php @@ -9,29 +9,54 @@ namespace Flarum\Mentions\Formatter; -use Psr\Http\Message\ServerRequestInterface as Request; +use Flarum\Http\SlugManager; +use Flarum\User\User; use s9e\TextFormatter\Renderer; use s9e\TextFormatter\Utils; +use Symfony\Contracts\Translation\TranslatorInterface; class FormatUserMentions { + /** + * @var SlugManager + */ + private $slugManager; + + /** + * @var TranslatorInterface + */ + private $translator; + + public function __construct(SlugManager $slugManager, TranslatorInterface $translator) + { + $this->slugManager = $slugManager; + $this->translator = $translator; + } + /** * Configure rendering for user mentions. * * @param s9e\TextFormatter\Renderer $renderer * @param mixed $context * @param string|null $xml - * @param Psr\Http\Message\ServerRequestInterface $request + * @return string $xml to be rendered */ - public function __invoke(Renderer $renderer, $context, $xml, Request $request = null) + public function __invoke(Renderer $renderer, $context, string $xml) { $post = $context; return Utils::replaceAttributes($xml, 'USERMENTION', function ($attributes) use ($post) { $user = $post->mentionsUsers->find($attributes['id']); + + $attributes['deleted'] = false; + if ($user) { - $attributes['username'] = $user->username; + $attributes['slug'] = $this->slugManager->forResource(User::class)->toSlug($user); $attributes['displayname'] = $user->display_name; + } else { + $attributes['deleted'] = true; + $attributes['slug'] = ''; + $attributes['displayname'] = $this->translator->trans('core.lib.username.deleted_text'); } return $attributes; diff --git a/extensions/mentions/src/Formatter/UnparsePostMentions.php b/extensions/mentions/src/Formatter/UnparsePostMentions.php new file mode 100644 index 000000000..4f8d01614 --- /dev/null +++ b/extensions/mentions/src/Formatter/UnparsePostMentions.php @@ -0,0 +1,95 @@ +translator = $translator; + } + + /** + * Configure rendering for user mentions. + * + * @param string $xml + * @param mixed $context + * @return string $xml to be unparsed + */ + public function __invoke($context, string $xml) + { + $xml = $this->updatePostMentionTags($context, $xml); + $xml = $this->unparsePostMentionTags($xml); + + return $xml; + } + + /** + * Updates XML post mention tags before unparsing so that unparsing uses new display names. + * + * @param mixed $context + * @param string $xml : Parsed text. + * @return string $xml : Updated XML tags; + */ + protected function updatePostMentionTags($context, string $xml): string + { + $post = $context; + + return Utils::replaceAttributes($xml, 'POSTMENTION', function ($attributes) use ($post) { + $post = $post->mentionsPosts->find($attributes['id']); + if ($post && $post->user) { + $attributes['displayname'] = $post->user->display_name; + } + + if (! $post) { + $attributes['displayname'] = $this->translator->trans('flarum-mentions.forum.post_mention.deleted_text'); + } + + if ($post && ! $post->user) { + $attributes['displayname'] = $this->translator->trans('core.lib.username.deleted_text'); + } + + if (strpos($attributes['displayname'], '"#') !== false) { + $attributes['displayname'] = preg_replace('/"#[a-z]{0,3}[0-9]+/', '_', $attributes['displayname']); + } + + return $attributes; + }); + } + + /** + * Transforms post mention tags from XML to raw unparsed content with updated format and display name. + * + * @param string $xml : Parsed text. + * @return string : Unparsed text. + */ + protected function unparsePostMentionTags(string $xml): string + { + $tagName = 'POSTMENTION'; + + if (strpos($xml, $tagName) === false) { + return $xml; + } + + return preg_replace( + '/<'.preg_quote($tagName).'\b[^>]*(?=\bdisplayname="(.*)")[^>]*(?=\bid="([0-9]+)")[^>]*>@[^<]+<\/'.preg_quote($tagName).'>/U', + '@"$1"#p$2', + $xml + ); + } +} diff --git a/extensions/mentions/src/Formatter/UnparseUserMentions.php b/extensions/mentions/src/Formatter/UnparseUserMentions.php new file mode 100644 index 000000000..694a2d5ed --- /dev/null +++ b/extensions/mentions/src/Formatter/UnparseUserMentions.php @@ -0,0 +1,91 @@ +translator = $translator; + } + + /** + * Configure rendering for user mentions. + * + * @param string $xml + * @param mixed $context + * @return string $xml to be unparsed + */ + public function __invoke($context, string $xml) + { + $xml = $this->updateUserMentionTags($context, $xml); + $xml = $this->unparseUserMentionTags($xml); + + return $xml; + } + + /** + * Updates XML user mention tags before unparsing so that unparsing uses new display names. + * + * @param mixed $context + * @param string $xml : Parsed text. + * @return string $xml : Updated XML tags; + */ + protected function updateUserMentionTags($context, string $xml): string + { + $post = $context; + + return Utils::replaceAttributes($xml, 'USERMENTION', function ($attributes) use ($post) { + $user = $post->mentionsUsers->find($attributes['id']); + + if ($user) { + $attributes['displayname'] = $user->display_name; + } else { + $attributes['displayname'] = $this->translator->trans('core.lib.username.deleted_text'); + } + + if (strpos($attributes['displayname'], '"#') !== false) { + $attributes['displayname'] = preg_replace('/"#[a-z]{0,3}[0-9]+/', '_', $attributes['displayname']); + } + + return $attributes; + }); + } + + /** + * Transforms user mention tags from XML to raw unparsed content with updated format and display name. + * + * @param string $xml : Parsed text. + * @return string : Unparsed text. + */ + protected function unparseUserMentionTags(string $xml): string + { + $tagName = 'USERMENTION'; + + if (strpos($xml, $tagName) === false) { + return $xml; + } + + return preg_replace( + '/<'.preg_quote($tagName).'\b[^>]*(?=\bdisplayname="(.*)")[^>]*(?=\bid="([0-9]+)")[^>]*>@[^<]+<\/'.preg_quote($tagName).'>/U', + '@"$1"#$2', + $xml + ); + } +} diff --git a/extensions/mentions/tests/integration/api/PostMentionsTest.php b/extensions/mentions/tests/integration/api/PostMentionsTest.php index 5d88dc8c2..c975a3072 100644 --- a/extensions/mentions/tests/integration/api/PostMentionsTest.php +++ b/extensions/mentions/tests/integration/api/PostMentionsTest.php @@ -36,6 +36,7 @@ class PostMentionsTest extends TestCase '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], @@ -43,10 +44,17 @@ class PostMentionsTest extends TestCase 'posts' => [ ['id' => 4, 'number' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '@tobyuuu#5'], ['id' => 5, 'number' => 3, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '@potato#4'], + ['id' => 6, 'number' => 4, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '@"i_am_a_deleted_user"#p7'], + ['id' => 7, 'number' => 5, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 2021, 'type' => 'comment', 'content' => '@"POTATO$"#2010'], + ['id' => 8, 'number' => 6, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '@"i_am_a_deleted_user"#p2020'], + ['id' => 9, 'number' => 10, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 5, 'type' => 'comment', 'content' => '

I am bad

'], + ['id' => 10, 'number' => 11, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '@"Bad "#p6 User"#p9'], ], 'post_mentions_post' => [ ['post_id' => 4, 'mentions_post_id' => 5], - ['post_id' => 5, 'mentions_post_id' => 4] + ['post_id' => 5, 'mentions_post_id' => 4], + ['post_id' => 6, 'mentions_post_id' => 7], + ['post_id' => 10, 'mentions_post_id' => 9] ], 'settings' => [ ['key' => 'display_name_driver', 'value' => 'custom_display_name_driver'], @@ -73,7 +81,7 @@ class PostMentionsTest extends TestCase /** * @test */ - public function mentioning_a_valid_post_works() + public function mentioning_a_valid_post_with_old_format_doesnt_work() { $this->app(); $this->recalculateDisplayNameDriver(); @@ -98,8 +106,42 @@ class PostMentionsTest extends TestCase $response = json_decode($response->getBody(), true); + $this->assertStringNotContainsString('POTATO$', $response['data']['attributes']['contentHtml']); + $this->assertEquals('@potato#4', $response['data']['attributes']['content']); + $this->assertStringNotContainsString('PostMention', $response['data']['attributes']['contentHtml']); + $this->assertNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(4)); + } + + /** + * @test + */ + public function mentioning_a_valid_post_with_new_format_works() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"POTATO$"#p4', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + $this->assertStringContainsString('POTATO$', $response['data']['attributes']['contentHtml']); - $this->assertStringContainsString('@potato#4', $response['data']['attributes']['content']); + $this->assertEquals('@"POTATO$"#p4', $response['data']['attributes']['content']); $this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']); $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(4)); } @@ -118,7 +160,7 @@ class PostMentionsTest extends TestCase 'json' => [ 'data' => [ 'attributes' => [ - 'content' => '@franzofflarum#215', + 'content' => '@"franzofflarum"#p215', ], 'relationships' => [ 'discussion' => ['data' => ['id' => 2]], @@ -133,7 +175,7 @@ class PostMentionsTest extends TestCase $response = json_decode($response->getBody(), true); $this->assertStringNotContainsString('FRANZOFFLARUM$', $response['data']['attributes']['contentHtml']); - $this->assertStringContainsString('@franzofflarum#215', $response['data']['attributes']['content']); + $this->assertEquals('@"franzofflarum"#p215', $response['data']['attributes']['content']); $this->assertStringNotContainsString('PostMention', $response['data']['attributes']['contentHtml']); $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsPosts); } @@ -152,7 +194,7 @@ class PostMentionsTest extends TestCase 'json' => [ 'data' => [ 'attributes' => [ - 'content' => '@toby#5 @flarum @franzofflarum#220 @potato @potato#4', + 'content' => '@"TOBY$"#p5 @"flarum"#2015 @"franzofflarum"#220 @"POTATO$"#3 @"POTATO$"#p4', ], 'relationships' => [ 'discussion' => ['data' => ['id' => 2]], @@ -169,7 +211,7 @@ class PostMentionsTest extends TestCase $this->assertStringContainsString('TOBY$', $response['data']['attributes']['contentHtml']); $this->assertStringNotContainsString('FRANZOFFLARUM$', $response['data']['attributes']['contentHtml']); $this->assertStringContainsString('POTATO$', $response['data']['attributes']['contentHtml']); - $this->assertEquals('@toby#5 @flarum @franzofflarum#220 @potato @potato#4', $response['data']['attributes']['content']); + $this->assertEquals('@"TOBY$"#p5 @"flarum"#2015 @"franzofflarum"#220 @"POTATO$"#3 @"POTATO$"#p4', $response['data']['attributes']['content']); $this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']); $this->assertCount(2, CommentPost::find($response['data']['id'])->mentionsPosts); } @@ -196,12 +238,211 @@ class PostMentionsTest extends TestCase $this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']); $this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsPosts); } + + /** + * @test + */ + public function post_mentions_unparse_with_fresh_data() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + + $response = $this->send( + $this->request('GET', '/api/posts/4', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('@"TOBY$"#p5', $response['data']['attributes']['content']); + $this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsPosts); + } + + /** + * @test + */ + public function deleted_post_mentions_s_user_unparse_and_render_without_user_data() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + $deleted_text = $this->app()->getContainer()->make('translator')->trans('core.lib.username.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('@"'.$deleted_text.'"#p7', $response['data']['attributes']['content']); + $this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']); + $this->assertStringNotContainsString('i_am_a_deleted_user', $response['data']['attributes']['contentHtml']); + $this->assertStringNotContainsString('i_am_a_deleted_user', $response['data']['attributes']['content']); + $this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsPosts); + } + + /** + * @test + */ + public function deleted_post_mentions_unparse_and_render_without_user_data() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + $deleted_text = $this->app()->getContainer()->make('translator')->trans('flarum-mentions.forum.post_mention.deleted_text'); + + $response = $this->send( + $this->request('GET', '/api/posts/7', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString($deleted_text, $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('@"'.$deleted_text.'"#p2010', $response['data']['attributes']['content']); + $this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']); + $this->assertStringNotContainsString('POTATO$', $response['data']['attributes']['contentHtml']); + $this->assertStringNotContainsString('POTATO$', $response['data']['attributes']['content']); + $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsPosts); + } + + /** + * @test + */ + public function deleted_post_mentions_and_deleted_user_unparse_and_render_without_user_data() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + $deleted_text = $this->app()->getContainer()->make('translator')->trans('flarum-mentions.forum.post_mention.deleted_text'); + + $response = $this->send( + $this->request('GET', '/api/posts/8', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString($deleted_text, $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('@"'.$deleted_text.'"#p2020', $response['data']['attributes']['content']); + $this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']); + $this->assertStringNotContainsString('POTATO$', $response['data']['attributes']['contentHtml']); + $this->assertStringNotContainsString('POTATO$', $response['data']['attributes']['content']); + $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsPosts); + } + + /** + * @test + */ + public function post_mentions_with_unremoved_bad_string_from_display_names_doesnt_work() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"Bad "#p6 User"#p9', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('POTATO$', $response['data']['attributes']['contentHtml']); + $this->assertEquals('@"POTATO$"#p6 User"#p9', $response['data']['attributes']['content']); + $this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']); + $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(6)); + } + + /** + * @test + */ + public function post_mentions_unparsing_removes_bad_display_name_string() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + + $response = $this->send( + $this->request('GET', '/api/posts/10', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('@"Bad _ User"#p9', $response['data']['attributes']['content']); + $this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']); + $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(9)); + } + + /** + * @test + */ + public function post_mentions_with_removed_bad_string_from_display_names_works() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"Bad _ User"#p9', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']); + $this->assertEquals('@"Bad _ User"#p9', $response['data']['attributes']['content']); + $this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']); + $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(9)); + } } class CustomOtherDisplayNameDriver implements DriverInterface { public function displayName(User $user): string { + if ($user->username === 'bad_user') { + return 'Bad "#p6 User'; + } + return strtoupper($user->username).'$'; } } diff --git a/extensions/mentions/tests/integration/api/UserMentionsTest.php b/extensions/mentions/tests/integration/api/UserMentionsTest.php index a23de7498..2bb1614a7 100644 --- a/extensions/mentions/tests/integration/api/UserMentionsTest.php +++ b/extensions/mentions/tests/integration/api/UserMentionsTest.php @@ -37,15 +37,19 @@ class UserMentionsTest extends TestCase $this->normalUser(), ['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' => '@tobyuuu'], + ['id' => 6, 'number' => 3, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '@"i_am_a_deleted_user"#2021'], + ['id' => 10, 'number' => 11, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 5, 'type' => 'comment', 'content' => '@"Bad "#p6 User"#5'], ], 'post_mentions_user' => [ - ['post_id' => 4, 'mentions_user_id' => 4] + ['post_id' => 4, 'mentions_user_id' => 4], + ['post_id' => 10, 'mentions_user_id' => 5] ], 'settings' => [ ['key' => 'display_name_driver', 'value' => 'custom_display_name_driver'], @@ -72,14 +76,60 @@ class UserMentionsTest extends TestCase /** * @test */ - public function mentioning_a_valid_user_works() + public function mentioning_a_valid_user_with_old_format_doesnt_work_if_off() { + $this->prepareDatabase([ + 'settings' => [ + ['key' => 'flarum-mentions.allow_username_format', 'value' => '0'] + ] + ]); + $this->app(); $this->recalculateDisplayNameDriver(); $response = $this->send( $this->request('POST', '/api/posts', [ - 'authenticatedAs' => 2, + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@potato', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringNotContainsString('@POTATO$', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('@potato', $response['data']['attributes']['content']); + $this->assertStringNotContainsString('UserMention', $response['data']['attributes']['contentHtml']); + $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsUsers); + } + + /** + * @test + */ + public function mentioning_a_valid_user_with_old_format_works_if_on() + { + $this->prepareDatabase([ + 'settings' => [ + ['key' => 'flarum-mentions.allow_username_format', 'value' => '1'] + ] + ]); + + $this->app(); + $this->recalculateDisplayNameDriver(); + + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, 'json' => [ 'data' => [ 'attributes' => [ @@ -98,7 +148,41 @@ class UserMentionsTest extends TestCase $response = json_decode($response->getBody(), true); $this->assertStringContainsString('@POTATO$', $response['data']['attributes']['contentHtml']); - $this->assertStringContainsString('@potato', $response['data']['attributes']['content']); + $this->assertEquals('@"POTATO$"#3', $response['data']['attributes']['content']); + $this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']); + $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(3)); + } + + /** + * @test + */ + public function mentioning_a_valid_user_with_new_format_works() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"POTATO$"#3', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('@POTATO$', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('@"POTATO$"#3', $response['data']['attributes']['content']); $this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']); $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(3)); } @@ -113,11 +197,11 @@ class UserMentionsTest extends TestCase $response = $this->send( $this->request('POST', '/api/posts', [ - 'authenticatedAs' => 2, + 'authenticatedAs' => 1, 'json' => [ 'data' => [ 'attributes' => [ - 'content' => '@franzofflarum', + 'content' => '@"franzofflarum"#82', ], 'relationships' => [ 'discussion' => ['data' => ['id' => 2]], @@ -132,7 +216,7 @@ class UserMentionsTest extends TestCase $response = json_decode($response->getBody(), true); $this->assertStringNotContainsString('@FRANZOFFLARUM$', $response['data']['attributes']['contentHtml']); - $this->assertStringContainsString('@franzofflarum', $response['data']['attributes']['content']); + $this->assertStringContainsString('@"franzofflarum"#82', $response['data']['attributes']['content']); $this->assertStringNotContainsString('UserMention', $response['data']['attributes']['contentHtml']); $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsUsers); } @@ -147,11 +231,11 @@ class UserMentionsTest extends TestCase $response = $this->send( $this->request('POST', '/api/posts', [ - 'authenticatedAs' => 2, + 'authenticatedAs' => 1, 'json' => [ 'data' => [ 'attributes' => [ - 'content' => '@toby @potato#4 @franzofflarum @potato', + 'content' => '@"TOBY$"#4 @"POTATO$"#p4 @"franzofflarum"#82 @"POTATO$"#3', ], 'relationships' => [ 'discussion' => ['data' => ['id' => 2]], @@ -168,7 +252,7 @@ class UserMentionsTest extends TestCase $this->assertStringContainsString('@TOBY$', $response['data']['attributes']['contentHtml']); $this->assertStringNotContainsString('@FRANZOFFLARUM$', $response['data']['attributes']['contentHtml']); $this->assertStringContainsString('@POTATO$', $response['data']['attributes']['contentHtml']); - $this->assertEquals('@toby @potato#4 @franzofflarum @potato', $response['data']['attributes']['content']); + $this->assertEquals('@"TOBY$"#4 @"POTATO$"#p4 @"franzofflarum"#82 @"POTATO$"#3', $response['data']['attributes']['content']); $this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']); $this->assertCount(2, CommentPost::find($response['data']['id'])->mentionsUsers); } @@ -176,7 +260,7 @@ class UserMentionsTest extends TestCase /** * @test */ - public function user_mentions_render_with_fresh_data() + public function old_user_mentions_still_render() { $this->app(); $this->recalculateDisplayNameDriver(); @@ -195,12 +279,201 @@ class UserMentionsTest extends TestCase $this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']); $this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsUsers); } + + /** + * @test + */ + public function user_mentions_render_with_fresh_data() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"potato_"#3', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('@POTATO$', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']); + $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(3)); + } + + /** + * @test + */ + public function user_mentions_unparse_with_fresh_data() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"potato_"#3', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('@"POTATO$"#3', $response['data']['attributes']['content']); + $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(3)); + } + + /** + * @test + */ + public function deleted_user_mentions_unparse_and_render_without_user_data() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + $deleted_text = $this->app()->getContainer()->make('translator')->trans('core.lib.username.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('@"'.$deleted_text.'"#2021', $response['data']['attributes']['content']); + $this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('UserMention--deleted', $response['data']['attributes']['contentHtml']); + $this->assertStringNotContainsString('i_am_a_deleted_user', $response['data']['attributes']['contentHtml']); + $this->assertStringNotContainsString('i_am_a_deleted_user', $response['data']['attributes']['content']); + $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsUsers); + } + + /** + * @test + */ + public function user_mentions_with_unremoved_bad_string_from_display_names_doesnt_work() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"Bad "#p6 User"#5', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringNotContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']); + $this->assertNotEquals('@"Bad "#p6 User"#5', $response['data']['attributes']['content']); + $this->assertStringNotContainsString('UserMention', $response['data']['attributes']['contentHtml']); + $this->assertNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(5)); + } + + /** + * @test + */ + public function user_mentions_unparsing_removes_bad_display_name_string() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + + $response = $this->send( + $this->request('GET', '/api/posts/10', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('@"Bad _ User"#5', $response['data']['attributes']['content']); + $this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']); + $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(5)); + } + + /** + * @test + */ + public function user_mentions_with_removed_bad_string_from_display_names_works() + { + $this->app(); + $this->recalculateDisplayNameDriver(); + + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '@"Bad _ User"#5', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']); + $this->assertEquals('@"Bad _ User"#5', $response['data']['attributes']['content']); + $this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']); + $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(5)); + } } class CustomDisplayNameDriver implements DriverInterface { public function displayName(User $user): string { + if ($user->username === 'bad_user') { + return 'Bad "#p6 User'; + } + return strtoupper($user->username).'$'; } }