From 5e281136f6f021c803dccc584e81c06df59455d1 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Wed, 19 Apr 2023 12:58:11 +0100 Subject: [PATCH] feat(mentions,tags): tag mentions (#3769) * feat: add tag search Signed-off-by: Sami Mazouz * feat(mentions): tag mentions backend Signed-off-by: Sami Mazouz * feat: tag mention design Signed-off-by: Sami Mazouz * refactor: revamp mentions autocomplete Signed-off-by: Sami Mazouz * fix: unauthorized mention of hidden groups Signed-off-by: Sami Mazouz * feat(mentions,tags): use hash format for tag mentions Signed-off-by: Sami Mazouz * refactor: frontend mention format API with mentionable models Signed-off-by: Sami Mazouz * feat: implement tag search on the frontend Signed-off-by: Sami Mazouz * fix: tag color contrast Signed-off-by: Sami Mazouz * fix: tag suggestions styling Signed-off-by: Sami Mazouz * test: works with disabled tags extension Signed-off-by: Sami Mazouz * chore: move `MentionFormats` to `formats` Signed-off-by: Sami Mazouz * fix: mentions preview bad styling Signed-off-by: Sami Mazouz * docs: further migration location clarification Signed-off-by: Sami Mazouz * Apply fixes from StyleCI * fix: bad test namespace Signed-off-by: Sami Mazouz * fix: phpstan Signed-off-by: Sami Mazouz * fix: conditionally add tag related extenders Signed-off-by: Sami Mazouz * Apply fixes from StyleCI * feat(phpstan): evaluate conditional extenders Signed-off-by: Sami Mazouz * feat: use mithril routing for tag mentions Signed-off-by: Sami Mazouz --------- Signed-off-by: Sami Mazouz Co-authored-by: StyleCI Bot --- extensions/mentions/composer.json | 4 + extensions/mentions/extend.php | 45 +- extensions/mentions/js/src/@types/shims.d.ts | 13 + .../js/src/forum/addComposerAutocomplete.js | 216 ++-------- .../js/src/forum/addPostMentionPreviews.js | 12 +- extensions/mentions/js/src/forum/compat.js | 6 + .../forum/components/MentionsDropdownItem.tsx | 25 ++ .../js/src/forum/extenders/Mentionables.ts | 54 +++ extensions/mentions/js/src/forum/index.js | 4 +- .../src/forum/mentionables/GroupMention.tsx | 72 ++++ .../forum/mentionables/MentionableModel.ts | 20 + .../forum/mentionables/MentionableModels.tsx | 93 +++++ .../js/src/forum/mentionables/PostMention.tsx | 102 +++++ .../js/src/forum/mentionables/TagMention.tsx | 65 +++ .../js/src/forum/mentionables/UserMention.tsx | 79 ++++ .../mentionables/formats/AtMentionFormat.ts | 27 ++ .../mentionables/formats/HashMentionFormat.ts | 22 + .../mentionables/formats/MentionFormat.ts | 26 ++ .../mentionables/formats/MentionFormats.ts | 26 ++ .../js/src/forum/utils/getMentionText.js | 40 +- .../mentions/js/src/forum/utils/reply.js | 4 +- .../js/src/forum/utils/textFormatter.js | 45 +- extensions/mentions/js/tsconfig.json | 1 + extensions/mentions/less/forum.less | 51 ++- extensions/mentions/src/ConfigureMentions.php | 156 ++++++- .../mentions/src/FilterVisiblePosts.php | 0 .../src/Formatter/CheckPermissions.php | 26 -- .../src/Formatter/FormatGroupMentions.php | 4 +- .../src/Formatter/FormatTagMentions.php | 41 ++ .../src/Formatter/UnparseTagMentions.php | 77 ++++ .../UpdateMentionsMetadataWhenInvisible.php | 13 +- .../UpdateMentionsMetadataWhenVisible.php | 21 +- .../integration/api/GroupMentionsTest.php | 46 +-- .../tests/integration/api/TagMentionsTest.php | 385 ++++++++++++++++++ extensions/tags/extend.php | 5 + extensions/tags/less/common/TagLabel.less | 28 +- ..._000000_create_post_mentions_tag_table.php | 49 +++ .../src/Api/Controller/ListTagsController.php | 45 +- .../tags/src/Search/Gambit/FulltextGambit.php | 48 +++ extensions/tags/src/Search/TagSearcher.php | 36 ++ extensions/tags/src/TagRepository.php | 5 + .../api/tags/ListWithFulltextSearchTest.php | 81 ++++ .../common/extenders/IExtender.d.ts | 4 +- .../core/js/src/common/extenders/IExtender.ts | 4 +- framework/core/src/Group/GroupRepository.php | 5 + .../phpstan/src/Extender/Resolver.php | 19 +- 46 files changed, 1786 insertions(+), 364 deletions(-) create mode 100644 extensions/mentions/js/src/forum/components/MentionsDropdownItem.tsx create mode 100644 extensions/mentions/js/src/forum/extenders/Mentionables.ts create mode 100644 extensions/mentions/js/src/forum/mentionables/GroupMention.tsx create mode 100644 extensions/mentions/js/src/forum/mentionables/MentionableModel.ts create mode 100644 extensions/mentions/js/src/forum/mentionables/MentionableModels.tsx create mode 100644 extensions/mentions/js/src/forum/mentionables/PostMention.tsx create mode 100644 extensions/mentions/js/src/forum/mentionables/TagMention.tsx create mode 100644 extensions/mentions/js/src/forum/mentionables/UserMention.tsx create mode 100644 extensions/mentions/js/src/forum/mentionables/formats/AtMentionFormat.ts create mode 100644 extensions/mentions/js/src/forum/mentionables/formats/HashMentionFormat.ts create mode 100644 extensions/mentions/js/src/forum/mentionables/formats/MentionFormat.ts create mode 100644 extensions/mentions/js/src/forum/mentionables/formats/MentionFormats.ts create mode 100755 extensions/mentions/src/FilterVisiblePosts.php delete mode 100644 extensions/mentions/src/Formatter/CheckPermissions.php create mode 100644 extensions/mentions/src/Formatter/FormatTagMentions.php create mode 100644 extensions/mentions/src/Formatter/UnparseTagMentions.php create mode 100644 extensions/mentions/tests/integration/api/TagMentionsTest.php create mode 100644 extensions/tags/migrations/2023_03_01_000000_create_post_mentions_tag_table.php create mode 100644 extensions/tags/src/Search/Gambit/FulltextGambit.php create mode 100644 extensions/tags/src/Search/TagSearcher.php create mode 100644 extensions/tags/tests/integration/api/tags/ListWithFulltextSearchTest.php diff --git a/extensions/mentions/composer.json b/extensions/mentions/composer.json index 00fe6dbe7a..741b255cb6 100644 --- a/extensions/mentions/composer.json +++ b/extensions/mentions/composer.json @@ -33,6 +33,9 @@ "flarum-extension": { "title": "Mentions", "category": "feature", + "optional-dependencies": [ + "flarum/tags" + ], "icon": { "name": "fas fa-at", "backgroundColor": "#539EC1", @@ -74,6 +77,7 @@ }, "require-dev": { "flarum/core": "*@dev", + "flarum/tags": "*@dev", "flarum/testing": "^1.0.0" }, "repositories": [ diff --git a/extensions/mentions/extend.php b/extensions/mentions/extend.php index d2d79ea9fc..8c74b5004e 100644 --- a/extensions/mentions/extend.php +++ b/extensions/mentions/extend.php @@ -26,6 +26,8 @@ use Flarum\Post\Event\Revised; use Flarum\Post\Filter\PostFilterer; use Flarum\Post\Post; +use Flarum\Tags\Api\Serializer\TagSerializer; +use Flarum\Tags\Tag; use Flarum\User\User; return [ @@ -42,14 +44,14 @@ ->render(Formatter\FormatUserMentions::class) ->render(Formatter\FormatGroupMentions::class) ->unparse(Formatter\UnparsePostMentions::class) - ->unparse(Formatter\UnparseUserMentions::class) - ->parse(Formatter\CheckPermissions::class), + ->unparse(Formatter\UnparseUserMentions::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('mentionsGroups', Group::class, 'post_mentions_group', 'post_id', 'mentions_group_id'), + ->belongsToMany('mentionsGroups', Group::class, 'post_mentions_group', 'post_id', 'mentions_group_id') + ->belongsToMany('mentionsUsers', User::class, 'post_mentions_user', 'post_id', 'mentions_user_id'), new Extend\Locales(__DIR__.'/locale'), @@ -83,7 +85,7 @@ (new Extend\ApiController(Controller\ListDiscussionsController::class)) ->load([ 'firstPost.mentionsUsers', 'firstPost.mentionsPosts', 'firstPost.mentionsPosts.user', 'firstPost.mentionsGroups', - 'lastPost.mentionsUsers', 'lastPost.mentionsPosts', 'lastPost.mentionsPosts.user', 'lastPost.mentionsGroups' + 'lastPost.mentionsUsers', 'lastPost.mentionsPosts', 'lastPost.mentionsPosts.user', 'lastPost.mentionsGroups', ]), (new Extend\ApiController(Controller\ShowPostController::class)) @@ -99,12 +101,6 @@ ->loadWhere('mentionedBy', [LoadMentionedByRelationship::class, 'mutateRelation']) ->prepareDataForSerialization([LoadMentionedByRelationship::class, 'countRelation']), - (new Extend\ApiController(Controller\CreatePostController::class)) - ->addOptionalInclude('mentionsGroups'), - - (new Extend\ApiController(Controller\UpdatePostController::class)) - ->addOptionalInclude('mentionsGroups'), - (new Extend\Settings) ->serializeToForum('allowUsernameMentionFormat', 'flarum-mentions.allow_username_format', 'boolval'), @@ -121,7 +117,32 @@ ->addFilter(Filter\MentionedPostFilter::class), (new Extend\ApiSerializer(CurrentUserSerializer::class)) - ->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user, array $attributes): bool { + ->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user): bool { return $user->can('mentionGroups'); - }) + }), + + // Tag mentions + (new Extend\Conditional()) + ->whenExtensionEnabled('flarum-tags', [ + (new Extend\Formatter) + ->render(Formatter\FormatTagMentions::class) + ->unparse(Formatter\UnparseTagMentions::class), + + (new Extend\Model(Post::class)) + ->belongsToMany('mentionsTags', Tag::class, 'post_mentions_tag', 'post_id', 'mentions_tag_id'), + + (new Extend\ApiSerializer(BasicPostSerializer::class)) + ->hasMany('mentionsTags', TagSerializer::class), + + (new Extend\ApiController(Controller\ShowDiscussionController::class)) + ->load(['posts.mentionsTags']), + + (new Extend\ApiController(Controller\ListDiscussionsController::class)) + ->load([ + 'firstPost.mentionsTags', 'lastPost.mentionsTags', + ]), + + (new Extend\ApiController(Controller\ListPostsController::class)) + ->load(['mentionsTags']), + ]), ]; diff --git a/extensions/mentions/js/src/@types/shims.d.ts b/extensions/mentions/js/src/@types/shims.d.ts index 1878233d13..dcb5c31a49 100644 --- a/extensions/mentions/js/src/@types/shims.d.ts +++ b/extensions/mentions/js/src/@types/shims.d.ts @@ -1,5 +1,18 @@ +import MentionFormats from '../forum/mentionables/formats/MentionFormats'; import type BasePost from 'flarum/common/models/Post'; +declare module 'flarum/forum/ForumApplication' { + export default interface ForumApplication { + mentionFormats: MentionFormats; + } +} + +declare module 'flarum/common/models/User' { + export default interface User { + canMentionGroups(): boolean; + } +} + declare module 'flarum/common/models/Post' { export default interface Post { mentionedBy(): BasePost[] | undefined | null; diff --git a/extensions/mentions/js/src/forum/addComposerAutocomplete.js b/extensions/mentions/js/src/forum/addComposerAutocomplete.js index 261c5f03db..bc8096cada 100644 --- a/extensions/mentions/js/src/forum/addComposerAutocomplete.js +++ b/extensions/mentions/js/src/forum/addComposerAutocomplete.js @@ -2,42 +2,15 @@ import app from 'flarum/forum/app'; import { extend } from 'flarum/common/extend'; import TextEditor from 'flarum/common/components/TextEditor'; import TextEditorButton from 'flarum/common/components/TextEditorButton'; -import ReplyComposer from 'flarum/forum/components/ReplyComposer'; -import EditPostComposer from 'flarum/forum/components/EditPostComposer'; -import avatar from 'flarum/common/helpers/avatar'; -import usernameHelper from 'flarum/common/helpers/username'; -import highlight from 'flarum/common/helpers/highlight'; import KeyboardNavigatable from 'flarum/common/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'; - -const throttledSearch = throttle( - 250, // 250ms timeout - function (typed, searched, returnedUsers, returnedUserIds, dropdown, buildSuggestions) { - const typedLower = typed.toLowerCase(); - if (!searched.includes(typedLower)) { - app.store.find('users', { filter: { q: typed }, page: { limit: 5 } }).then((results) => { - results.forEach((u) => { - if (!returnedUserIds.has(u.id())) { - returnedUserIds.add(u.id()); - returnedUsers.push(u); - } - }); - - buildSuggestions(); - }); - - searched.push(typedLower); - } - } -); +import MentionFormats from './mentionables/formats/MentionFormats'; +import MentionableModels from './mentionables/MentionableModels'; export default function addComposerAutocomplete() { + app.mentionFormats = new MentionFormats(); + const $container = $('
'); const dropdown = new AutocompleteDropdown(); @@ -57,47 +30,42 @@ export default function addComposerAutocomplete() { }); extend(TextEditor.prototype, 'buildEditorParams', function (params) { - const searched = []; let relMentionStart; let absMentionStart; - let typed; let matchTyped; - // We store users returned from an API here to preserve order in which they are returned - // This prevents the user list jumping around while users are returned. - // We also use a hashset for user IDs to provide O(1) lookup for the users already in the list. - const returnedUsers = Array.from(app.store.all('users')); - const returnedUserIds = new Set(returnedUsers.map((u) => u.id())); + let mentionables = new MentionableModels({ + onmouseenter: function () { + dropdown.setIndex($(this).parent().index()); + }, + onclick: (replacement) => { + this.attrs.composer.editor.replaceBeforeCursor(absMentionStart - 1, replacement + ' '); - // 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 + ' '); - - dropdown.hide(); - }; + dropdown.hide(); + }, + }); - params.inputListeners.push(() => { + const suggestionsInputListener = () => { const selection = this.attrs.composer.editor.getSelectionRange(); const cursor = selection[0]; if (selection[1] - cursor > 0) return; - // Search backwards from the cursor for an '@' symbol. If we find one, - // we will want to show the autocomplete dropdown! + // Search backwards from the cursor for a mention triggering symbol. If we find one, + // we will want to show the correct autocomplete dropdown! + // Check classes implementing the IMentionableModel interface to see triggering symbols. const lastChunk = this.attrs.composer.editor.getLastNChars(30); absMentionStart = 0; + let activeFormat = null; for (let i = lastChunk.length - 1; i >= 0; i--) { const character = lastChunk.substr(i, 1); - if (character === '@' && (i == 0 || /\s/.test(lastChunk.substr(i - 1, 1)))) { + activeFormat = app.mentionFormats.get(character); + + if (activeFormat && (i === 0 || /\s/.test(lastChunk.substr(i - 1, 1)))) { relMentionStart = i + 1; absMentionStart = cursor - lastChunk.length + i + 1; + mentionables.init(activeFormat.makeMentionables()); break; } } @@ -106,132 +74,14 @@ export default function addComposerAutocomplete() { dropdown.active = false; 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); - - if (typed) { - username.children = [highlight(username.text, typed)]; - delete username.text; - } - - return ( - - ); - }; - - 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 typed = lastChunk.substring(relMentionStart).toLowerCase(); + matchTyped = activeFormat.queryFromTyped(typed); + mentionables.typed = matchTyped || typed; const buildSuggestions = () => { - const suggestions = []; - - // If the user has started to type a username, then suggest users - // matching that username. - if (typed) { - returnedUsers.forEach((user) => { - if (!userMatches(user)) return; - - 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 - // post, then we can suggest other posts in the discussion to mention. - // We will add the 5 most recent comments in the discussion which - // match any username characters that have been typed. - if (this.attrs.composer.bodyMatches(ReplyComposer) || this.attrs.composer.bodyMatches(EditPostComposer)) { - const composerAttrs = this.attrs.composer.body.attrs; - const composerPost = composerAttrs.post; - const discussion = (composerPost && composerPost.discussion()) || composerAttrs.discussion; - - if (discussion) { - discussion - .posts() - // Filter to only comment posts, and replies before this message - .filter((post) => post && post.contentType() === 'comment' && (!composerPost || post.number() < composerPost.number())) - // Sort by new to old - .sort((a, b) => b.createdAt() - a.createdAt()) - // Filter to where the user matches what is being typed - .filter((post) => { - const user = post.user(); - return user && userMatches(user); - }) - // Get the first 5 - .splice(0, 5) - // Make the suggestions - .forEach((post) => { - const user = post.user(); - suggestions.push( - makeSuggestion( - user, - getMentionText(user, post.id()), - [ - app.translator.trans('flarum-mentions.forum.composer.reply_to_post_text', { number: post.number() }), - ' — ', - truncate(post.contentPlain(), 200), - ], - 'MentionsDropdown-post' - ) - ); - }); - } - } + // If the user has started to type a mention, + // then suggest models matching. + const suggestions = mentionables.buildSuggestions(); if (suggestions.length) { dropdown.items = suggestions; @@ -271,13 +121,11 @@ export default function addComposerAutocomplete() { dropdown.setIndex(0); dropdown.$().scrollTop(0); - // Don't send API calls searching for users until at least 2 characters have been typed. - // This focuses the mention results on users and posts in the discussion. - if (typed.length > 1 && app.forum.attribute('canSearchUsers')) { - throttledSearch(typed, searched, returnedUsers, returnedUserIds, dropdown, buildSuggestions); - } + mentionables.search()?.then(buildSuggestions); } - }); + }; + + params.inputListeners.push(suggestionsInputListener); }); extend(TextEditor.prototype, 'toolbarItems', function (items) { diff --git a/extensions/mentions/js/src/forum/addPostMentionPreviews.js b/extensions/mentions/js/src/forum/addPostMentionPreviews.js index 3fd8d1db57..57b1b7c595 100644 --- a/extensions/mentions/js/src/forum/addPostMentionPreviews.js +++ b/extensions/mentions/js/src/forum/addPostMentionPreviews.js @@ -14,10 +14,14 @@ export default function addPostMentionPreviews() { const parentPost = this.attrs.post; const $parentPost = this.$(); - this.$().on('click', '.UserMention:not(.UserMention--deleted), .PostMention:not(.PostMention--deleted)', function (e) { - m.route.set(this.getAttribute('href')); - e.preventDefault(); - }); + this.$().on( + 'click', + '.UserMention:not(.UserMention--deleted), .PostMention:not(.PostMention--deleted), .TagMention:not(.TagMention--deleted)', + function (e) { + m.route.set(this.getAttribute('href')); + e.preventDefault(); + } + ); this.$('.PostMention:not(.PostMention--deleted)').each(function () { const $this = $(this); diff --git a/extensions/mentions/js/src/forum/compat.js b/extensions/mentions/js/src/forum/compat.js index ee22c67732..8c456f1894 100644 --- a/extensions/mentions/js/src/forum/compat.js +++ b/extensions/mentions/js/src/forum/compat.js @@ -9,6 +9,9 @@ import getMentionText from './utils/getMentionText'; import * as reply from './utils/reply'; import selectedText from './utils/selectedText'; import * as textFormatter from './utils/textFormatter'; +import MentionableModel from './mentionables/MentionableModel'; +import MentionFormat from './mentionables/formats/MentionFormat'; +import Mentionables from './extenders/Mentionables'; export default { 'mentions/components/MentionsUserPage': MentionsUserPage, @@ -22,4 +25,7 @@ export default { 'mentions/utils/reply': reply, 'mentions/utils/selectedText': selectedText, 'mentions/utils/textFormatter': textFormatter, + 'mentions/mentionables/MentionableModel': MentionableModel, + 'mentions/mentionables/formats/MentionFormat': MentionFormat, + 'mentions/extenders/Mentionables': Mentionables, }; diff --git a/extensions/mentions/js/src/forum/components/MentionsDropdownItem.tsx b/extensions/mentions/js/src/forum/components/MentionsDropdownItem.tsx new file mode 100644 index 0000000000..48e0ffaadf --- /dev/null +++ b/extensions/mentions/js/src/forum/components/MentionsDropdownItem.tsx @@ -0,0 +1,25 @@ +import Component from 'flarum/common/Component'; +import type { ComponentAttrs } from 'flarum/common/Component'; +import classList from 'flarum/common/utils/classList'; +import type MentionableModel from '../mentionables/MentionableModel'; +import type Mithril from 'mithril'; + +export interface IMentionsDropdownItemAttrs extends ComponentAttrs { + mentionable: MentionableModel; + onclick: () => void; + onmouseenter: () => void; +} + +export default class MentionsDropdownItem extends Component { + view(vnode: Mithril.Vnode): Mithril.Children { + const { mentionable, ...attrs } = this.attrs; + + const className = classList('MentionsDropdownItem', 'PostPreview', `MentionsDropdown-${mentionable.type()}`); + + return ( + + ); + } +} diff --git a/extensions/mentions/js/src/forum/extenders/Mentionables.ts b/extensions/mentions/js/src/forum/extenders/Mentionables.ts new file mode 100644 index 0000000000..cf2db51f7d --- /dev/null +++ b/extensions/mentions/js/src/forum/extenders/Mentionables.ts @@ -0,0 +1,54 @@ +import type ForumApplication from 'flarum/forum/ForumApplication'; +import type IExtender from 'flarum/common/extenders/IExtender'; +import type MentionableModel from '../mentionables/MentionableModel'; +import type MentionFormat from '../mentionables/formats/MentionFormat'; + +export default class Mentionables implements IExtender { + protected formats: (new () => MentionFormat)[] = []; + protected mentionables: Record MentionableModel)[]> = {}; + + /** + * Register a new mention format. + * Must extend MentionFormat and have a unique unused trigger symbol. + */ + format(format: new () => MentionFormat): this { + this.formats.push(format); + + return this; + } + + /** + * Register a new mentionable model to a mention format. + * Only works if the format has already been registered, + * and the format allows using multiple mentionables. + * + * @param symbol The trigger symbol of the format to extend (ex: @). + * @param mentionable The mentionable instance to register. + * Must extend MentionableModel. + */ + mentionable(symbol: string, mentionable: new () => MentionableModel): this { + if (!this.mentionables[symbol]) { + this.mentionables[symbol] = []; + } + + this.mentionables[symbol].push(mentionable); + + return this; + } + + extend(app: ForumApplication): void { + for (const format of this.formats) { + app.mentionFormats.extend(format); + } + + for (const symbol in this.mentionables) { + const format = app.mentionFormats.get(symbol); + + if (!format) continue; + + for (const mentionable of this.mentionables[symbol]) { + format.extend(mentionable); + } + } + } +} diff --git a/extensions/mentions/js/src/forum/index.js b/extensions/mentions/js/src/forum/index.js index 40910b656b..10e606d742 100644 --- a/extensions/mentions/js/src/forum/index.js +++ b/extensions/mentions/js/src/forum/index.js @@ -87,8 +87,8 @@ app.initializers.add('flarum-mentions', function () { // Apply color contrast fix on group mentions. extend(Post.prototype, 'oncreate', function () { - this.$('.GroupMention--colored').each(function () { - this.classList.add(textContrastClass(getComputedStyle(this).getPropertyValue('--group-color'))); + this.$('.GroupMention--colored, .TagMention--colored').each(function () { + this.classList.add(textContrastClass(getComputedStyle(this).getPropertyValue('--color'))); }); }); }); diff --git a/extensions/mentions/js/src/forum/mentionables/GroupMention.tsx b/extensions/mentions/js/src/forum/mentionables/GroupMention.tsx new file mode 100644 index 0000000000..7703db5dd7 --- /dev/null +++ b/extensions/mentions/js/src/forum/mentionables/GroupMention.tsx @@ -0,0 +1,72 @@ +import app from 'flarum/forum/app'; +import Group from 'flarum/common/models/Group'; +import MentionableModel from './MentionableModel'; +import type Mithril from 'mithril'; +import Badge from 'flarum/common/components/Badge'; +import highlight from 'flarum/common/helpers/highlight'; +import type AtMentionFormat from './formats/AtMentionFormat'; + +export default class GroupMention extends MentionableModel { + type(): string { + return 'group'; + } + + initialResults(): Group[] { + return Array.from( + app.store.all('groups').filter((g: Group) => { + return g.id() !== Group.GUEST_ID && g.id() !== Group.MEMBER_ID; + }) + ); + } + + /** + * Generates the mention syntax for a group mention. + * + * @"Name Plural"#gGroupID + * + * @example Group mention + * // '@"Mods"#g4' + * forGroup(group) // Group display name is 'Mods', group ID is 4 + */ + public replacement(group: Group): string { + return this.format.format(group.namePlural(), 'g', group.id()); + } + + suggestion(model: Group, typed: string): Mithril.Children { + let groupName: Mithril.Children = model.namePlural(); + + if (typed) { + groupName = highlight(groupName, typed); + } + + return ( + <> + + {groupName} + + ); + } + + matches(model: Group, typed: string): boolean { + if (!typed) return false; + + const names = [model.namePlural().toLowerCase(), model.nameSingular().toLowerCase()]; + + return names.some((name) => name.toLowerCase().substr(0, typed.length) === typed); + } + + maxStoreMatchedResults(): null { + return null; + } + + /** + * All groups are already loaded, so we don't need to search for them. + */ + search(typed: string): Promise { + return Promise.resolve([]); + } + + enabled(): boolean { + return app.session?.user?.canMentionGroups() ?? false; + } +} diff --git a/extensions/mentions/js/src/forum/mentionables/MentionableModel.ts b/extensions/mentions/js/src/forum/mentionables/MentionableModel.ts new file mode 100644 index 0000000000..65b8499e48 --- /dev/null +++ b/extensions/mentions/js/src/forum/mentionables/MentionableModel.ts @@ -0,0 +1,20 @@ +import type Mithril from 'mithril'; +import type Model from 'flarum/common/Model'; +import type MentionFormat from './formats/MentionFormat'; + +export default abstract class MentionableModel { + public format: Format; + + public constructor(format: Format) { + this.format = format; + } + + abstract type(): string; + abstract initialResults(): M[]; + abstract search(typed: string): Promise; + abstract replacement(model: M): string; + abstract suggestion(model: M, typed: string): Mithril.Children; + abstract matches(model: M, typed: string): boolean; + abstract maxStoreMatchedResults(): number | null; + abstract enabled(): boolean; +} diff --git a/extensions/mentions/js/src/forum/mentionables/MentionableModels.tsx b/extensions/mentions/js/src/forum/mentionables/MentionableModels.tsx new file mode 100644 index 0000000000..2095051fac --- /dev/null +++ b/extensions/mentions/js/src/forum/mentionables/MentionableModels.tsx @@ -0,0 +1,93 @@ +import MentionFormats from './MentionFormats'; +import type MentionableModel from './MentionableModel'; +import type Model from 'flarum/common/Model'; +import type Mithril from 'mithril'; +import MentionsDropdownItem from '../components/MentionsDropdownItem'; +import { throttle } from 'flarum/common/utils/throttleDebounce'; + +export default class MentionableModels { + protected mentionables?: MentionableModel[]; + /** + * We store models returned from an API here to preserve order in which they are returned + * This prevents the list jumping around while models are returned. + * We also use a hashmap for model IDs to provide O(1) lookup for the users already in the list. + */ + private results: Record> = {}; + public typed: string | null = null; + private searched: string[] = []; + private dropdownItemAttrs: Record = {}; + + constructor(dropdownItemAttrs: Record) { + this.dropdownItemAttrs = dropdownItemAttrs; + } + + public init(mentionables: MentionableModel[]): void { + this.typed = null; + this.mentionables = mentionables; + + for (const mentionable of this.mentionables) { + this.results[mentionable.type()] = new Map(mentionable.initialResults().map((result) => [result.id() as string, result])); + } + } + + /** + * Don't send API calls searching for models until at least 2 characters have been typed. + * This focuses the mention results on models already loaded. + */ + public readonly search = throttle(250, async (): Promise => { + if (!this.typed || this.typed.length <= 1) return; + + const typedLower = this.typed.toLowerCase(); + + if (this.searched.includes(typedLower)) return; + + for (const mentionable of this.mentionables!) { + for (const model of await mentionable.search(typedLower)) { + if (!this.results[mentionable.type()].has(model.id() as string)) { + this.results[mentionable.type()].set(model.id() as string, model); + } + } + } + + this.searched.push(typedLower); + + return Promise.resolve(); + }); + + public matches(mentionable: MentionableModel, model: Model): boolean { + return mentionable.matches(model, this.typed?.toLowerCase() || ''); + } + + public makeSuggestion(mentionable: MentionableModel, model: Model): Mithril.Children { + const content = mentionable.suggestion(model, this.typed!); + const replacement = mentionable.replacement(model); + + const { onclick, ...attrs } = this.dropdownItemAttrs; + + return ( + onclick(replacement)} {...attrs}> + {content} + + ); + } + + public buildSuggestions(): Mithril.Children { + const suggestions: Mithril.Children = []; + + for (const mentionable of this.mentionables!) { + if (!mentionable.enabled()) continue; + + let matches = Array.from(this.results[mentionable.type()].values()).filter((model) => this.matches(mentionable, model)); + + const max = mentionable.maxStoreMatchedResults(); + if (max) matches = matches.splice(0, max); + + for (const model of matches) { + const dropdownItem = this.makeSuggestion(mentionable, model); + suggestions.push(dropdownItem); + } + } + + return suggestions; + } +} diff --git a/extensions/mentions/js/src/forum/mentionables/PostMention.tsx b/extensions/mentions/js/src/forum/mentionables/PostMention.tsx new file mode 100644 index 0000000000..2901d9e95f --- /dev/null +++ b/extensions/mentions/js/src/forum/mentionables/PostMention.tsx @@ -0,0 +1,102 @@ +import app from 'flarum/forum/app'; +import MentionableModel from './MentionableModel'; +import type Post from 'flarum/common/models/Post'; +import type Mithril from 'mithril'; +import usernameHelper from 'flarum/common/helpers/username'; +import avatar from 'flarum/common/helpers/avatar'; +import highlight from 'flarum/common/helpers/highlight'; +import { truncate } from 'flarum/common/utils/string'; +import ReplyComposer from 'flarum/forum/components/ReplyComposer'; +import EditPostComposer from 'flarum/forum/components/EditPostComposer'; +import getCleanDisplayName from '../utils/getCleanDisplayName'; +import type AtMentionFormat from './formats/AtMentionFormat'; + +export default class PostMention extends MentionableModel { + type(): string { + return 'post'; + } + + /** + * If the user is replying to a discussion, or if they are editing a + * post, then we can suggest other posts in the discussion to mention. + * We will add the 5 most recent comments in the discussion which + * match any username characters that have been typed. + */ + initialResults(): Post[] { + if (!app.composer.bodyMatches(ReplyComposer) && !app.composer.bodyMatches(EditPostComposer)) { + return []; + } + + // @ts-ignore + const composerAttrs = app.composer.body.attrs; + const composerPost = composerAttrs.post; + const discussion = (composerPost && composerPost.discussion()) || composerAttrs.discussion; + + return ( + discussion + .posts() + // Filter to only comment posts, and replies before this message + .filter((post: Post) => post && post.contentType() === 'comment' && (!composerPost || post.number() < composerPost.number())) + // Sort by new to old + .sort((a: Post, b: Post) => b.createdAt().getTime() - a.createdAt().getTime()) + ); + } + + /** + * Generates the syntax for mentioning of a post. Also cleans up the display name. + * + * @example Post mention + * // '@"User"#p13' + * // @"Display name"#pPostID + * forPostMention(user, 13) // User display name is 'User', post ID is 13 + */ + public replacement(post: Post): string { + const user = post.user(); + const cleanText = getCleanDisplayName(user); + return this.format.format(cleanText, 'p', post.id()); + } + + suggestion(model: Post, typed: string): Mithril.Children { + const user = model.user() || null; + const username = usernameHelper(user); + + if (typed) { + username.children = [highlight((username.text ?? '') as string, typed)]; + delete username.text; + } + + return ( + <> + {avatar(user)} + {username} + {[ + app.translator.trans('flarum-mentions.forum.composer.reply_to_post_text', { number: model.number() }), + ' — ', + truncate(model.contentPlain() ?? '', 200), + ]} + + ); + } + + matches(model: Post, typed: string): boolean { + const user = model.user(); + const userMentionable = app.mentionFormats.mentionable('user')!; + + return !typed || (user && userMentionable.matches(user, typed)); + } + + maxStoreMatchedResults(): number { + return 5; + } + + /** + * Post mention suggestions are only offered from current discussion posts. + */ + search(typed: string): Promise { + return Promise.resolve([]); + } + + enabled(): boolean { + return true; + } +} diff --git a/extensions/mentions/js/src/forum/mentionables/TagMention.tsx b/extensions/mentions/js/src/forum/mentionables/TagMention.tsx new file mode 100644 index 0000000000..eaa480d9a7 --- /dev/null +++ b/extensions/mentions/js/src/forum/mentionables/TagMention.tsx @@ -0,0 +1,65 @@ +import app from 'flarum/forum/app'; +import Badge from 'flarum/common/components/Badge'; +import highlight from 'flarum/common/helpers/highlight'; +import type Tag from 'flarum/tags/common/models/Tag'; +import type Mithril from 'mithril'; +import MentionableModel from './MentionableModel'; +import type HashMentionFormat from './formats/HashMentionFormat'; + +export default class TagMention extends MentionableModel { + type(): string { + return 'tag'; + } + + initialResults(): Tag[] { + return Array.from(app.store.all('tags')); + } + + /** + * Generates the mention syntax for a tag mention. + * + * ~tagSlug + * + * @example Tag mention + * // ~general + * forTag(tag) // Tag display name is 'Tag', tag ID is 5 + */ + public replacement(tag: Tag): string { + return this.format.format(tag.slug()); + } + + matches(model: Tag, typed: string): boolean { + if (!typed) return false; + + const names = [model.name().toLowerCase()]; + + return names.some((name) => name.toLowerCase().substr(0, typed.length) === typed); + } + + maxStoreMatchedResults(): null { + return null; + } + + async search(typed: string): Promise { + return await app.store.find('tags', { filter: { q: typed }, page: { limit: 5 } }); + } + + suggestion(model: Tag, typed: string): Mithril.Children { + let tagName: Mithril.Children = model.name(); + + if (typed) { + tagName = highlight(tagName, typed); + } + + return ( + <> + + {tagName} + + ); + } + + enabled(): boolean { + return 'flarum-tags' in flarum.extensions; + } +} diff --git a/extensions/mentions/js/src/forum/mentionables/UserMention.tsx b/extensions/mentions/js/src/forum/mentionables/UserMention.tsx new file mode 100644 index 0000000000..e7b3c5be23 --- /dev/null +++ b/extensions/mentions/js/src/forum/mentionables/UserMention.tsx @@ -0,0 +1,79 @@ +import app from 'flarum/forum/app'; +import type Mithril from 'mithril'; +import type User from 'flarum/common/models/User'; +import usernameHelper from 'flarum/common/helpers/username'; +import avatar from 'flarum/common/helpers/avatar'; +import highlight from 'flarum/common/helpers/highlight'; +import MentionableModel from './MentionableModel'; +import getCleanDisplayName, { shouldUseOldFormat } from '../utils/getCleanDisplayName'; +import AtMentionFormat from './formats/AtMentionFormat'; + +export default class UserMention extends MentionableModel { + type(): string { + return 'user'; + } + + initialResults(): User[] { + return Array.from(app.store.all('users')); + } + + /** + * Automatically determines which mention syntax to be used based on the option in the + * admin dashboard. Also performs display name clean-up automatically. + * + * @"Display name"#UserID or `@username` + * + * @example New display name syntax + * // '@"user"#1' + * forUser(User) // User is ID 1, display name is 'User' + * + * @example Using old syntax + * // '@username' + * forUser(user) // User's username is 'username' + */ + public replacement(user: User): string { + if (shouldUseOldFormat()) { + const cleanText = getCleanDisplayName(user, false); + return this.format.format(cleanText); + } + + const cleanText = getCleanDisplayName(user); + return this.format.format(cleanText, '', user.id()); + } + + suggestion(model: User, typed: string): Mithril.Children { + const username = usernameHelper(model); + + if (typed) { + username.children = [highlight((username.text ?? '') as string, typed)]; + delete username.text; + } + + return ( + <> + {avatar(model)} + {username} + + ); + } + + matches(model: User, typed: string): boolean { + if (!typed) return false; + + const names = [model.username(), model.displayName()]; + + return names.some((name) => name.toLowerCase().substr(0, typed.length) === typed); + } + + maxStoreMatchedResults(): null { + return null; + } + + async search(typed: string): Promise { + return await app.store.find('users', { filter: { q: typed }, page: { limit: 5 } }); + } + + enabled(): boolean { + return true; + } +} diff --git a/extensions/mentions/js/src/forum/mentionables/formats/AtMentionFormat.ts b/extensions/mentions/js/src/forum/mentionables/formats/AtMentionFormat.ts new file mode 100644 index 0000000000..8b64859673 --- /dev/null +++ b/extensions/mentions/js/src/forum/mentionables/formats/AtMentionFormat.ts @@ -0,0 +1,27 @@ +import MentionFormat from './MentionFormat'; +import type MentionableModel from '../MentionableModel'; +import UserMention from '../UserMention'; +import PostMention from '../PostMention'; +import GroupMention from '../GroupMention'; + +export default class AtMentionFormat extends MentionFormat { + public mentionables: (new (...args: any[]) => MentionableModel)[] = [UserMention, PostMention, GroupMention]; + protected extendable: boolean = true; + + public trigger(): string { + return '@'; + } + + public queryFromTyped(typed: string): string | null { + const matchTyped = typed.match(/^["“]((?:(?!"#).)+)$/); + + return matchTyped ? matchTyped[1] : null; + } + + public format(name: string, char: string | null = '', id: string | null = null): string { + return { + simple: `@${name}`, + safe: `@"${name}"#${char}${id}`, + }[id ? 'safe' : 'simple']; + } +} diff --git a/extensions/mentions/js/src/forum/mentionables/formats/HashMentionFormat.ts b/extensions/mentions/js/src/forum/mentionables/formats/HashMentionFormat.ts new file mode 100644 index 0000000000..2132a62cc8 --- /dev/null +++ b/extensions/mentions/js/src/forum/mentionables/formats/HashMentionFormat.ts @@ -0,0 +1,22 @@ +import MentionFormat from './MentionFormat'; +import MentionableModel from '../MentionableModel'; +import TagMention from '../TagMention'; + +export default class HashMentionFormat extends MentionFormat { + public mentionables: (new (...args: any[]) => MentionableModel)[] = [TagMention]; + protected extendable: boolean = false; + + public trigger(): string { + return '#'; + } + + public queryFromTyped(typed: string): string | null { + const matchTyped = typed.match(/^[-_\p{L}\p{N}\p{M}]+$/giu); + + return matchTyped ? matchTyped[1] : null; + } + + public format(slug: string): string { + return `#${slug}`; + } +} diff --git a/extensions/mentions/js/src/forum/mentionables/formats/MentionFormat.ts b/extensions/mentions/js/src/forum/mentionables/formats/MentionFormat.ts new file mode 100644 index 0000000000..6b95d9d833 --- /dev/null +++ b/extensions/mentions/js/src/forum/mentionables/formats/MentionFormat.ts @@ -0,0 +1,26 @@ +import type MentionableModel from '../MentionableModel'; +import type Model from 'flarum/common/Model'; + +export default abstract class MentionFormat { + protected instances?: MentionableModel[]; + + public makeMentionables(): MentionableModel[] { + return this.instances ?? (this.instances = this.mentionables.map((Mentionable) => new Mentionable(this))); + } + + public getMentionable(type: string): MentionableModel | null { + return this.makeMentionables().find((mentionable) => mentionable.type() === type) ?? null; + } + + public extend(mentionable: new (...args: any[]) => MentionableModel): void { + if (!this.extendable) throw new Error('This mention format does not allow extending.'); + + this.mentionables.push(mentionable); + } + + abstract mentionables: (new (...args: any[]) => MentionableModel)[]; + protected abstract extendable: boolean; + abstract trigger(): string; + abstract queryFromTyped(typed: string): string | null; + abstract format(...args: any): string; +} diff --git a/extensions/mentions/js/src/forum/mentionables/formats/MentionFormats.ts b/extensions/mentions/js/src/forum/mentionables/formats/MentionFormats.ts new file mode 100644 index 0000000000..f053e6e374 --- /dev/null +++ b/extensions/mentions/js/src/forum/mentionables/formats/MentionFormats.ts @@ -0,0 +1,26 @@ +import AtMentionFormat from './AtMentionFormat'; +import HashMentionFormat from './HashMentionFormat'; +import type MentionFormat from './MentionFormat'; +import MentionableModel from '../MentionableModel'; + +export default class MentionFormats { + protected formats: MentionFormat[] = [new AtMentionFormat(), new HashMentionFormat()]; + + public get(symbol: string): MentionFormat | null { + return this.formats.find((f) => f.trigger() === symbol) ?? null; + } + + public mentionable(type: string): MentionableModel | null { + for (const format of this.formats) { + const mentionable = format.getMentionable(type); + + if (mentionable) return mentionable; + } + + return null; + } + + public extend(format: new () => MentionFormat) { + this.formats.push(new format()); + } +} diff --git a/extensions/mentions/js/src/forum/utils/getMentionText.js b/extensions/mentions/js/src/forum/utils/getMentionText.js index 6a99ee38ed..446ffca844 100644 --- a/extensions/mentions/js/src/forum/utils/getMentionText.js +++ b/extensions/mentions/js/src/forum/utils/getMentionText.js @@ -1,45 +1,21 @@ -import getCleanDisplayName, { shouldUseOldFormat } from './getCleanDisplayName'; +import app from 'flarum/forum/app'; /** - * Fetches the mention text for a specified user (and optionally a post ID for replies, or group). + * 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. * - * @example New display name syntax - * // '@"User"#1' - * getMentionText(User) // User is ID 1, display name is 'User' - * - * @example Replying - * // '@"User"#p13' - * getMentionText(User, 13) // User display name is 'User', post ID is 13 - * - * @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 + * @deprecated Use `app.mentionables.get('user').replacement(user)` instead. Will be removed in 2.0. */ export default function getMentionText(user, postId, group) { if (user !== undefined && postId === undefined) { - if (shouldUseOldFormat()) { - // Plain @username - const cleanText = getCleanDisplayName(user, false); - return `@${cleanText}`; - } - // @"Display name"#UserID - const cleanText = getCleanDisplayName(user); - return `@"${cleanText}"#${user.id()}`; + return app.mentionables.get('user').replacement(user); } else if (user !== undefined && postId !== undefined) { - // @"Display name"#pPostID - const cleanText = getCleanDisplayName(user); - return `@"${cleanText}"#p${postId}`; + return app.mentionables.get('post').replacement(app.store.getById('posts', postId)); } else if (group !== undefined) { - // @"Name Plural"#gGroupID - return `@"${group.namePlural()}"#g${group.id()}`; - } else { - throw 'No parameters were passed'; + return app.mentionables.get('group').replacement(group); } + + throw 'No parameters were passed'; } diff --git a/extensions/mentions/js/src/forum/utils/reply.js b/extensions/mentions/js/src/forum/utils/reply.js index 3fdf7fec98..5a2961e77b 100644 --- a/extensions/mentions/js/src/forum/utils/reply.js +++ b/extensions/mentions/js/src/forum/utils/reply.js @@ -1,12 +1,10 @@ import app from 'flarum/forum/app'; import DiscussionControls from 'flarum/forum/utils/DiscussionControls'; import EditPostComposer from 'flarum/forum/components/EditPostComposer'; -import getMentionText from './getMentionText'; export function insertMention(post, composer, quote) { return new Promise((resolve) => { - const user = post.user(); - const mention = getMentionText(user, post.id()) + ' '; + const mention = app.mentionFormats.mentionable('post').replacement(post) + ' '; // 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 415d999071..f947bf191f 100644 --- a/extensions/mentions/js/src/forum/utils/textFormatter.js +++ b/extensions/mentions/js/src/forum/utils/textFormatter.js @@ -20,6 +20,10 @@ export function filterUserMentions(tag) { tag.invalidate(); } +export function postFilterUserMentions(tag) { + tag.setAttribute('deleted', false); +} + export function filterPostMentions(tag) { const post = app.store.getById('posts', tag.getAttribute('id')); @@ -32,14 +36,16 @@ export function filterPostMentions(tag) { } } +export function postFilterPostMentions(tag) { + tag.setAttribute('deleted', false); +} + 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; } @@ -47,3 +53,38 @@ export function filterGroupMentions(tag) { tag.invalidate(); } + +export function postFilterGroupMentions(tag) { + if (app.session?.user?.canMentionGroups()) { + const group = app.store.getById('groups', tag.getAttribute('id')); + + tag.setAttribute('color', group.color()); + tag.setAttribute('icon', group.icon()); + tag.setAttribute('deleted', false); + } +} + +export function filterTagMentions(tag) { + if ('flarum-tags' in flarum.extensions) { + const model = app.store.getBy('tags', 'slug', tag.getAttribute('slug')); + + if (model) { + tag.setAttribute('id', model.id()); + tag.setAttribute('tagname', model.name()); + + return true; + } + } + + tag.invalidate(); +} + +export function postFilterTagMentions(tag) { + if ('flarum-tags' in flarum.extensions) { + const model = app.store.getBy('tags', 'slug', tag.getAttribute('slug')); + + tag.setAttribute('icon', model.icon()); + tag.setAttribute('color', model.color()); + tag.setAttribute('deleted', false); + } +} diff --git a/extensions/mentions/js/tsconfig.json b/extensions/mentions/js/tsconfig.json index f427c289ee..f05741cea4 100644 --- a/extensions/mentions/js/tsconfig.json +++ b/extensions/mentions/js/tsconfig.json @@ -10,6 +10,7 @@ "declarationDir": "./dist-typings", "paths": { "flarum/*": ["../../../framework/core/js/dist-typings/*"], + "flarum/tags/*": ["../../tags/js/dist-typings/*"], // TODO: remove after export registry system implemented // Without this, the old-style `@flarum/core` import is resolved to // source code in flarum/core instead of the dist typings. diff --git a/extensions/mentions/less/forum.less b/extensions/mentions/less/forum.less index 8177e4d1ce..d5d9e819fc 100644 --- a/extensions/mentions/less/forum.less +++ b/extensions/mentions/less/forum.less @@ -2,8 +2,6 @@ background: var(--control-bg); color: var(--control-color); border-radius: @border-radius; - padding: 2px 5px; - border: 0 !important; font-weight: 600; blockquote & { @@ -14,7 +12,12 @@ color: var(--link-color); } } -.UserMention, .PostMention, .GroupMention { +.UserMention, .PostMention, .GroupMention, .TagMention { + padding: 2px 5px; + vertical-align: middle; + border: 0 !important; + white-space: nowrap; + &--deleted { opacity: 0.8; filter: grayscale(1); @@ -27,12 +30,38 @@ margin-left: 0; } + // @TODO: 2.0 use an icon in the XSLT template. &:before { .fas(); content: @fa-var-reply; margin-right: 5px; } } +.GroupMention { + background-color: var(--color, var(--control-bg)); + color: var(--control-color); + --link-color: currentColor; + + &--colored { + --control-color: var(--contrast-color, var(--body-bg)); + --link-color: var(--control-color); + } + + .icon { + margin-left: 5px; + } +} +& when (is-extension-enabled('flarum-tags')) { + .TagMention { + --tag-bg: var(--color, var(--control-bg)); + .tag-label(); + margin: 0 2px; + + .icon { + margin-right: 2px; + } + } +} .ComposerBody-mentionsWrapper { position: relative; } @@ -50,6 +79,7 @@ } } .MentionsDropdown, .PostMention-preview, .Post-mentionedBy-preview { + // @TODO: Rename to .MentionsDropdownItem, along with child classes. 2.0 .PostPreview { color: @muted-color; @@ -97,24 +127,9 @@ position: absolute; .Button--color(@tooltip-color, @tooltip-bg); } -.GroupMention { - background-color: var(--group-color, var(--control-bg)); - color: var(--control-color); - --link-color: currentColor; - - &--colored { - --control-color: var(--contrast-color, var(--body-bg)); - --link-color: var(--control-color); - } - - .icon { - margin-left: 5px; - } -} .MentionsDropdown .Badge { box-shadow: none; } - @media @phone { .MentionsDropdown { max-width: 100%; diff --git a/extensions/mentions/src/ConfigureMentions.php b/extensions/mentions/src/ConfigureMentions.php index 7f47beec25..18a156fe2f 100644 --- a/extensions/mentions/src/ConfigureMentions.php +++ b/extensions/mentions/src/ConfigureMentions.php @@ -9,14 +9,21 @@ namespace Flarum\Mentions; +use Flarum\Extension\ExtensionManager; use Flarum\Group\Group; +use Flarum\Group\GroupRepository; use Flarum\Http\UrlGenerator; use Flarum\Post\PostRepository; use Flarum\Settings\SettingsRepositoryInterface; +use Flarum\Tags\Tag; +use Flarum\Tags\TagRepository; use Flarum\User\User; use s9e\TextFormatter\Configurator; -use s9e\TextFormatter\Parser\Tag; +use s9e\TextFormatter\Parser\Tag as FormatterTag; +/** + * @TODO: refactor this lump of code into a mentionable models polymorphic system (for v2.0). + */ class ConfigureMentions { /** @@ -25,11 +32,14 @@ class ConfigureMentions protected $url; /** - * @param UrlGenerator $url + * @var ExtensionManager */ - public function __construct(UrlGenerator $url) + protected $extensions; + + public function __construct(UrlGenerator $url, ExtensionManager $extensions) { $this->url = $url; + $this->extensions = $extensions; } public function __invoke(Configurator $config) @@ -37,6 +47,10 @@ public function __invoke(Configurator $config) $this->configureUserMentions($config); $this->configurePostMentions($config); $this->configureGroupMentions($config); + + if ($this->extensions->isEnabled('flarum-tags')) { + $this->configureTagMentions($config); + } } private function configureUserMentions(Configurator $config): void @@ -58,15 +72,19 @@ private function configureUserMentions(Configurator $config): void @ '; + $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); + $tag->filterChain->append([static::class, 'dummyFilter']) + ->setJs('function(tag) { return flarum.extensions["flarum-mentions"].postFilterUserMentions(tag); }'); + + $config->Preg->match('/\B@["“](?((?!"#[a-z]{0,3}[0-9]+).)+)["”]#(?[0-9]+)\b/', $tagName); $config->Preg->match('/\B@(?[a-z0-9_-]+)(?!#)/i', $tagName); } /** - * @param Tag $tag + * @param FormatterTag $tag * @return bool|void */ public static function addUserId($tag) @@ -117,11 +135,14 @@ private function configurePostMentions(Configurator $config): void ->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterPostMentions(tag); }') ->addParameterByName('actor'); - $config->Preg->match('/\B@["|“](?((?!"#[a-z]{0,3}[0-9]+).)+)["|”]#p(?[0-9]+)\b/', $tagName); + $tag->filterChain->append([static::class, 'dummyFilter']) + ->setJs('function(tag) { return flarum.extensions["flarum-mentions"].postFilterPostMentions(tag); }'); + + $config->Preg->match('/\B@["“](?((?!"#[a-z]{0,3}[0-9]+).)+)["”]#p(?[0-9]+)\b/', $tagName); } /** - * @param Tag $tag + * @param FormatterTag $tag * @return bool|void */ public static function addPostId($tag, User $actor) @@ -148,8 +169,6 @@ private function configureGroupMentions(Configurator $config) $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 = ' @@ -157,7 +176,7 @@ private function configureGroupMentions(Configurator $config) - + @ @@ -183,29 +202,130 @@ private function configureGroupMentions(Configurator $config) '; + $tag->filterChain->prepend([static::class, 'addGroupId']) - ->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterGroupMentions(tag); }'); + ->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterGroupMentions(tag); }') + ->addParameterByName('actor'); + + $tag->filterChain->append([static::class, 'dummyFilter']) + ->setJS('function(tag) { return flarum.extensions["flarum-mentions"].postFilterGroupMentions(tag); }'); - $config->Preg->match('/\B@["|“](?((?!"#[a-z]{0,3}[0-9]+).)+)["|”]#g(?[0-9]+)\b/', $tagName); + $config->Preg->match('/\B@["“](?((?!"#[a-z]{0,3}[0-9]+).)+)["|”]#g(?[0-9]+)\b/', $tagName); } /** - * @param $tag * @return bool|void */ - public static function addGroupId($tag) + public static function addGroupId(FormatterTag $tag, User $actor) { - $group = Group::find($tag->getAttribute('id')); + $id = $tag->getAttribute('id'); + + if ($actor->cannot('mentionGroups') || in_array($id, [Group::GUEST_ID, Group::MEMBER_ID])) { + $tag->invalidate(); + + return false; + } - if (isset($group) && ! in_array($group->id, [Group::GUEST_ID, Group::MEMBER_ID])) { + $group = resolve(GroupRepository::class) + ->queryVisibleTo($actor) + ->find($id); + + if ($group) { $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(); } + + private function configureTagMentions(Configurator $config) + { + $config->rendering->parameters['TAG_URL'] = $this->url->to('forum')->route('tag', ['slug' => '']); + + $tagName = 'TAGMENTION'; + + $tag = $config->tags->add($tagName); + $tag->attributes->add('tagname'); + $tag->attributes->add('slug'); + $tag->attributes->add('id')->filterChain->append('#uint'); + + $tag->template = ' + + + + + + + TagMention TagMention--colored + + + TagMention + + + + + + + --color: + + + + + + + + + + + + + + + + + + + + '; + + $tag->filterChain + ->prepend([static::class, 'addTagId']) + ->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterTagMentions(tag); }') + ->addParameterByName('actor'); + + $tag->filterChain + ->append([static::class, 'dummyFilter']) + ->setJS('function(tag) { return flarum.extensions["flarum-mentions"].postFilterTagMentions(tag); }'); + + $config->Preg->match('/(?:[^“"]|^)\B#(?[-_\p{L}\p{N}\p{M}]+)\b/ui', $tagName); + } + + /** + * @return true|void + */ + public static function addTagId(FormatterTag $tag, User $actor) + { + /** @var Tag|null $model */ + $model = resolve(TagRepository::class) + ->queryVisibleTo($actor) + ->firstWhere('slug', $tag->getAttribute('slug')); + + if ($model) { + $tag->setAttribute('id', (string) $model->id); + $tag->setAttribute('tagname', $model->name); + + return true; + } + } + + /** + * Used when only an append JS filter is needed, + * to add post tag validation attributes. + */ + public static function dummyFilter(): bool + { + return true; + } } diff --git a/extensions/mentions/src/FilterVisiblePosts.php b/extensions/mentions/src/FilterVisiblePosts.php new file mode 100755 index 0000000000..e69de29bb2 diff --git a/extensions/mentions/src/Formatter/CheckPermissions.php b/extensions/mentions/src/Formatter/CheckPermissions.php deleted file mode 100644 index c6a899b459..0000000000 --- a/extensions/mentions/src/Formatter/CheckPermissions.php +++ /dev/null @@ -1,26 +0,0 @@ -cannot('mentionGroups')) { - $parser->disableTag('GROUPMENTION'); - } - - return $text; - } -} diff --git a/extensions/mentions/src/Formatter/FormatGroupMentions.php b/extensions/mentions/src/Formatter/FormatGroupMentions.php index 713166cc18..985d6f3218 100644 --- a/extensions/mentions/src/Formatter/FormatGroupMentions.php +++ b/extensions/mentions/src/Formatter/FormatGroupMentions.php @@ -39,8 +39,8 @@ 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']); + ? $context->mentionsGroups->find($attributes['id']) + : Group::find($attributes['id']); if ($group) { $attributes['groupname'] = $group->name_plural; diff --git a/extensions/mentions/src/Formatter/FormatTagMentions.php b/extensions/mentions/src/Formatter/FormatTagMentions.php new file mode 100644 index 0000000000..c74c83e69e --- /dev/null +++ b/extensions/mentions/src/Formatter/FormatTagMentions.php @@ -0,0 +1,41 @@ +getRelations()['mentionsTags'])) || $context instanceof Post) + ? $context->mentionsTags->find($attributes['id']) + : Tag::query()->find($attributes['id']); + + if ($tag) { + $attributes['deleted'] = false; + $attributes['tagname'] = $tag->name; + $attributes['slug'] = $tag->slug; + $attributes['color'] = $tag->color ?? ''; + $attributes['icon'] = $tag->icon ?? ''; + } else { + $attributes['deleted'] = true; + } + + return $attributes; + }); + } +} diff --git a/extensions/mentions/src/Formatter/UnparseTagMentions.php b/extensions/mentions/src/Formatter/UnparseTagMentions.php new file mode 100644 index 0000000000..b2cae82f2f --- /dev/null +++ b/extensions/mentions/src/Formatter/UnparseTagMentions.php @@ -0,0 +1,77 @@ +updateTagMentionTags($context, $xml); + $xml = $this->unparseTagMentionTags($xml); + + return $xml; + } + + /** + * Updates XML user mention tags before unparsing so that unparsing uses new tag names. + * + * @param mixed $context + * @param string $xml : Parsed text. + * @return string $xml : Updated XML tags; + */ + protected function updateTagMentionTags($context, string $xml): string + { + return Utils::replaceAttributes($xml, 'TAGMENTION', function (array $attributes) use ($context) { + /** @var Tag|null $tag */ + $tag = (($context && isset($context->getRelations()['mentionsTags'])) || $context instanceof Post) + ? $context->mentionsTags->find($attributes['id']) + : Tag::query()->find($attributes['id']); + + if ($tag) { + $attributes['tagname'] = $tag->name; + $attributes['slug'] = $tag->slug; + } + + return $attributes; + }); + } + + /** + * Transforms tag mention tags from XML to raw unparsed content with updated name. + * + * @param string $xml : Parsed text. + * @return string : Unparsed text. + */ + protected function unparseTagMentionTags(string $xml): string + { + $tagName = 'TAGMENTION'; + + if (strpos($xml, $tagName) === false) { + return $xml; + } + + return preg_replace( + '/<'.preg_quote($tagName).'\b[^>]*(?=\bid="([0-9]+)")[^>]*(?=\bslug="(.*)")[^>]*>@[^<]+<\/'.preg_quote($tagName).'>/U', + '#$2', + $xml + ); + } +} diff --git a/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenInvisible.php b/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenInvisible.php index fafe2a850b..e3a82543a3 100755 --- a/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenInvisible.php +++ b/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenInvisible.php @@ -9,6 +9,7 @@ namespace Flarum\Mentions\Listener; +use Flarum\Extension\ExtensionManager; use Flarum\Mentions\Notification\UserMentionedBlueprint; use Flarum\Notification\NotificationSyncer; use Flarum\Post\Event\Deleted; @@ -22,11 +23,14 @@ class UpdateMentionsMetadataWhenInvisible protected $notifications; /** - * @param NotificationSyncer $notifications + * @var ExtensionManager */ - public function __construct(NotificationSyncer $notifications) + protected $extensions; + + public function __construct(NotificationSyncer $notifications, ExtensionManager $extensions) { $this->notifications = $notifications; + $this->extensions = $extensions; } /** @@ -43,5 +47,10 @@ public function handle($event) // Remove group mentions $event->post->mentionsGroups()->sync([]); + + // Remove tag mentions + if ($this->extensions->isEnabled('flarum-tags')) { + $event->post->mentionsTags()->sync([]); + } } } diff --git a/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenVisible.php b/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenVisible.php index 7a7c4f19c6..df0a1af1ff 100755 --- a/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenVisible.php +++ b/extensions/mentions/src/Listener/UpdateMentionsMetadataWhenVisible.php @@ -10,6 +10,7 @@ namespace Flarum\Mentions\Listener; use Flarum\Approval\Event\PostWasApproved; +use Flarum\Extension\ExtensionManager; use Flarum\Mentions\Notification\GroupMentionedBlueprint; use Flarum\Mentions\Notification\PostMentionedBlueprint; use Flarum\Mentions\Notification\UserMentionedBlueprint; @@ -30,11 +31,14 @@ class UpdateMentionsMetadataWhenVisible protected $notifications; /** - * @param NotificationSyncer $notifications + * @var ExtensionManager */ - public function __construct(NotificationSyncer $notifications) + protected $extensions; + + public function __construct(NotificationSyncer $notifications, ExtensionManager $extensions) { $this->notifications = $notifications; + $this->extensions = $extensions; } /** @@ -62,6 +66,13 @@ public function handle($event) $event->post, Utils::getAttributeValues($content, 'GROUPMENTION', 'id') ); + + if ($this->extensions->isEnabled('flarum-tags')) { + $this->syncTagMentions( + $event->post, + Utils::getAttributeValues($content, 'TAGMENTION', 'id') + ); + } } protected function syncUserMentions(Post $post, array $mentioned) @@ -113,4 +124,10 @@ protected function syncGroupMentions(Post $post, array $mentioned) $this->notifications->sync(new GroupMentionedBlueprint($post), $users); } + + protected function syncTagMentions(Post $post, array $mentioned) + { + $post->mentionsTags()->sync($mentioned); + $post->unsetRelation('mentionsTags'); + } } diff --git a/extensions/mentions/tests/integration/api/GroupMentionsTest.php b/extensions/mentions/tests/integration/api/GroupMentionsTest.php index f001ba2bf0..f4c60b487b 100644 --- a/extensions/mentions/tests/integration/api/GroupMentionsTest.php +++ b/extensions/mentions/tests/integration/api/GroupMentionsTest.php @@ -33,40 +33,30 @@ protected function setUp(): void '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

'], + ['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_user' => [ + ['group_id' => 9, 'user_id' => 4], + ], 'group_permission' => [ ['group_id' => Group::MEMBER_ID, 'permission' => 'postWithoutThrottle'], + ['group_id' => 9, 'permission' => 'mentionGroups'], ], '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 - ] + ['id' => 9, 'name_singular' => 'HasPermissionToMentionGroups', 'name_plural' => 'test'], + ['id' => 10, 'name_singular' => 'Hidden', 'name_plural' => 'Ninjas', 'icon' => 'fas fa-wrench', 'color' => '#000', 'is_hidden' => 1], + ['id' => 11, 'name_singular' => 'Fresh Name', 'name_plural' => 'Fresh Name', 'color' => '#ccc', 'icon' => 'fas fa-users', 'is_hidden' => 0] ] ]); } @@ -324,15 +314,9 @@ public function user_without_permission_cannot_mention_groups() */ 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, + 'authenticatedAs' => 4, 'json' => [ 'data' => [ 'attributes' => [ @@ -361,15 +345,9 @@ public function user_with_permission_can_mention_groups() */ 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, + 'authenticatedAs' => 4, 'json' => [ 'data' => [ 'attributes' => [ diff --git a/extensions/mentions/tests/integration/api/TagMentionsTest.php b/extensions/mentions/tests/integration/api/TagMentionsTest.php new file mode 100644 index 0000000000..f478a96d1d --- /dev/null +++ b/extensions/mentions/tests/integration/api/TagMentionsTest.php @@ -0,0 +1,385 @@ +extension('flarum-tags', '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], + ], + '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' => '#test_old_slug'], + ['id' => 7, 'number' => 5, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 2021, 'type' => 'comment', 'content' => '#deleted_relation'], + ['id' => 8, 'number' => 6, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '#i_am_a_deleted_tag'], + ['id' => 10, 'number' => 11, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '#laravel'], + ], + 'tags' => [ + ['id' => 1, 'name' => 'Test', 'slug' => 'test', 'is_restricted' => 0], + ['id' => 2, 'name' => 'Flarum', 'slug' => 'flarum', 'is_restricted' => 0], + ['id' => 3, 'name' => 'Support', 'slug' => 'support', 'is_restricted' => 0], + ['id' => 4, 'name' => 'Dev', 'slug' => 'dev', 'is_restricted' => 1], + ['id' => 5, 'name' => 'Laravel "#t6 Tag', 'slug' => 'laravel', 'is_restricted' => 0], + ['id' => 6, 'name' => 'Tatakai', 'slug' => '戦い', 'is_restricted' => 0], + ], + 'post_mentions_tag' => [ + ['post_id' => 4, 'mentions_tag_id' => 1], + ['post_id' => 5, 'mentions_tag_id' => 2], + ['post_id' => 6, 'mentions_tag_id' => 3], + ['post_id' => 10, 'mentions_tag_id' => 4], + ['post_id' => 10, 'mentions_tag_id' => 5], + ], + 'group_permission' => [ + ['group_id' => Group::MEMBER_ID, 'permission' => 'postWithoutThrottle'], + ], + ]); + } + + /** @test */ + public function mentioning_a_valid_tag_with_valid_format_works() + { + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '#flarum', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('TagMention', $response['data']['attributes']['contentHtml']); + $this->assertStringNotContainsString('TagMention--deleted', $response['data']['attributes']['contentHtml']); + $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsTags->find(2)); + } + + /** @test */ + public function mentioning_a_valid_tag_using_cjk_slug_with_valid_format_works() + { + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '#戦い', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('Tatakai', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('TagMention', $response['data']['attributes']['contentHtml']); + $this->assertStringNotContainsString('TagMention--deleted', $response['data']['attributes']['contentHtml']); + $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsTags->find(6)); + } + + /** @test */ + public function mentioning_an_invalid_tag_doesnt_work() + { + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '#franzofflarum', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertEquals('#franzofflarum', $response['data']['attributes']['content']); + $this->assertStringNotContainsString('TagMention', $response['data']['attributes']['contentHtml']); + $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsTags); + } + + /** @test */ + public function mentioning_a_tag_when_tags_disabled_does_not_cause_errors() + { + $this->extensions = ['flarum-mentions']; + + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '#test', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertEquals('#test', $response['data']['attributes']['content']); + $this->assertStringNotContainsString('TagMention', $response['data']['attributes']['contentHtml']); + $this->assertNull(CommentPost::find($response['data']['id'])->mentionsTags); + } + + /** @test */ + public function mentioning_a_restricted_tag_doesnt_work_without_privileges() + { + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 3, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '#dev', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertEquals('#dev', $response['data']['attributes']['content']); + $this->assertStringNotContainsString('TagMention', $response['data']['attributes']['contentHtml']); + $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsTags); + } + + /** @test */ + public function mentioning_a_restricted_tag_works_with_privileges() + { + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '#dev', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertEquals('#dev', $response['data']['attributes']['content']); + $this->assertStringContainsString('TagMention', $response['data']['attributes']['contentHtml']); + $this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsTags); + } + + /** @test */ + public function mentioning_multiple_tags_works() + { + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '#test #flarum #support #laravel #franzofflarum', + ], + 'relationships' => [ + 'discussion' => ['data' => ['id' => 2]], + ], + ], + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('Test', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('Flarum', $response['data']['attributes']['contentHtml']); + $this->assertEquals('#test #flarum #support #laravel #franzofflarum', $response['data']['attributes']['content']); + $this->assertStringContainsString('TagMention', $response['data']['attributes']['contentHtml']); + $this->assertStringNotContainsString('TagMention--deleted', $response['data']['attributes']['contentHtml']); + $this->assertCount(4, CommentPost::find($response['data']['id'])->mentionsTags); + } + + /** @test */ + public function tag_mentions_render_with_fresh_data() + { + $response = $this->send( + $this->request('GET', '/api/posts/4', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('Test', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('TagMention', $response['data']['attributes']['contentHtml']); + $this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsTags); + } + + /** @test */ + public function tag_mentions_dont_cause_errors_when_tags_disabled() + { + $this->extensions = ['flarum-mentions']; + + $response = $this->send( + $this->request('GET', '/api/posts/4', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** @test */ + public function tag_mentions_unparse_with_fresh_data() + { + $response = $this->send( + $this->request('GET', '/api/posts/4', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('#test', $response['data']['attributes']['content']); + $this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsTags); + } + + /** @test */ + public function deleted_tag_mentions_unparse_and_render_as_expected() + { + // No reason to hide a deleted tag's name. + $deleted_text = 'i_am_a_deleted_tag'; + + $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", $response['data']['attributes']['content']); + $this->assertStringContainsString('TagMention', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('TagMention--deleted', $response['data']['attributes']['contentHtml']); + $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsTags); + } + + /** @test */ + public function deleted_tag_mentions_relation_unparse_and_render_as_expected() + { + // No reason to hide a deleted tag's name. + $deleted_text = 'deleted_relation'; + + $response = $this->send( + $this->request('GET', '/api/posts/7', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('Support', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString("#$deleted_text", $response['data']['attributes']['content']); + $this->assertStringContainsString('TagMention', $response['data']['attributes']['contentHtml']); + $this->assertStringContainsString('TagMention--deleted', $response['data']['attributes']['contentHtml']); + $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsTags); + } + + /** @test */ + public function editing_a_post_that_has_a_tag_mention_works() + { + $response = $this->send( + $this->request('PATCH', '/api/posts/10', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => '#laravel', + ], + ], + ], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $response = json_decode($response->getBody(), true); + + $this->assertStringContainsString('Laravel "#t6 Tag', $response['data']['attributes']['contentHtml']); + $this->assertEquals('#laravel', $response['data']['attributes']['content']); + $this->assertStringContainsString('TagMention', $response['data']['attributes']['contentHtml']); + $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsTags->find(5)); + } +} diff --git a/extensions/tags/extend.php b/extensions/tags/extend.php index 97a6263583..8f2d875c3e 100644 --- a/extensions/tags/extend.php +++ b/extensions/tags/extend.php @@ -29,6 +29,8 @@ use Flarum\Tags\LoadForumTagsRelationship; use Flarum\Tags\Post\DiscussionTaggedPost; use Flarum\Tags\Query\TagFilterGambit; +use Flarum\Tags\Search\Gambit\FulltextGambit; +use Flarum\Tags\Search\TagSearcher; use Flarum\Tags\Tag; use Flarum\Tags\Utf8SlugDriver; use Psr\Http\Message\ServerRequestInterface; @@ -135,6 +137,9 @@ (new Extend\SimpleFlarumSearch(DiscussionSearcher::class)) ->addGambit(TagFilterGambit::class), + (new Extend\SimpleFlarumSearch(TagSearcher::class)) + ->setFullTextGambit(FullTextGambit::class), + (new Extend\ModelUrl(Tag::class)) ->addSlugDriver('default', Utf8SlugDriver::class), ]; diff --git a/extensions/tags/less/common/TagLabel.less b/extensions/tags/less/common/TagLabel.less index 533349edfc..e5c6e84f79 100644 --- a/extensions/tags/less/common/TagLabel.less +++ b/extensions/tags/less/common/TagLabel.less @@ -1,35 +1,39 @@ -.TagLabel { - font-size: 85%; +.tag-label() { font-weight: 600; - display: inline-block; - padding: 0.1em 0.5em; - border-radius: @border-radius; + border-radius: var(--border-radius); background: var(--tag-bg); color: var(--tag-color); text-transform: none; text-decoration: none !important; - vertical-align: bottom; &.untagged { --tag-bg: transparent; - --tag-color: @muted-color; + --tag-color: var(--muted-color); border: 1px dotted; } - &.colored { + &.colored, &--colored { --tag-color: var(--contrast-color, var(--body-bg)); + } - .TagLabel-text { - color: var(--tag-color) !important; - } + &.colored &-text, &--colored &-text { + color: var(--tag-color) !important; } +} + +.TagLabel { + .tag-label(); + font-size: 85%; + display: inline-block; + padding: 0.1em 0.5em; + vertical-align: bottom; .DiscussionHero .TagsLabel & { background: transparent; border-radius: @border-radius !important; font-size: 14px; - &.colored { + &.colored, &--colored { --tag-color: var(--tag-bg); margin-right: 5px; background-color: var(--contrast-color, var(--body-bg)); diff --git a/extensions/tags/migrations/2023_03_01_000000_create_post_mentions_tag_table.php b/extensions/tags/migrations/2023_03_01_000000_create_post_mentions_tag_table.php new file mode 100644 index 0000000000..c802dc7076 --- /dev/null +++ b/extensions/tags/migrations/2023_03_01_000000_create_post_mentions_tag_table.php @@ -0,0 +1,49 @@ +unsignedInteger('post_id'); + $table->foreign('post_id') + ->references('id') + ->on('posts') + ->cascadeOnDelete(); + $table->unsignedInteger('mentions_tag_id'); + $table->foreign('mentions_tag_id') + ->references('id') + ->on('tags') + ->cascadeOnDelete(); + $table->dateTime('created_at')->useCurrent()->nullable(); + + $table->primary(['post_id', 'mentions_tag_id']); + } +); diff --git a/extensions/tags/src/Api/Controller/ListTagsController.php b/extensions/tags/src/Api/Controller/ListTagsController.php index 755f718f24..d78b5339e5 100644 --- a/extensions/tags/src/Api/Controller/ListTagsController.php +++ b/extensions/tags/src/Api/Controller/ListTagsController.php @@ -11,7 +11,10 @@ use Flarum\Api\Controller\AbstractListController; use Flarum\Http\RequestUtil; +use Flarum\Http\UrlGenerator; +use Flarum\Query\QueryCriteria; use Flarum\Tags\Api\Serializer\TagSerializer; +use Flarum\Tags\Search\TagSearcher; use Flarum\Tags\TagRepository; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -44,9 +47,21 @@ class ListTagsController extends AbstractListController */ protected $tags; - public function __construct(TagRepository $tags) + /** + * @var TagSearcher + */ + protected $searcher; + + /** + * @var UrlGenerator + */ + protected $url; + + public function __construct(TagRepository $tags, TagSearcher $searcher, UrlGenerator $url) { $this->tags = $tags; + $this->searcher = $searcher; + $this->url = $url; } /** @@ -56,15 +71,33 @@ protected function data(ServerRequestInterface $request, Document $document) { $actor = RequestUtil::getActor($request); $include = $this->extractInclude($request); + $filters = $this->extractFilter($request); + $limit = $this->extractLimit($request); + $offset = $this->extractOffset($request); if (in_array('lastPostedDiscussion', $include)) { $include = array_merge($include, ['lastPostedDiscussion.tags', 'lastPostedDiscussion.state']); } - return $this->tags - ->with($include, $actor) - ->whereVisibleTo($actor) - ->withStateFor($actor) - ->get(); + if (array_key_exists('q', $filters)) { + $results = $this->searcher->search(new QueryCriteria($actor, $filters), $limit, $offset); + $tags = $results->getResults(); + + $document->addPaginationLinks( + $this->url->to('api')->route('tags.index'), + $request->getQueryParams(), + $offset, + $limit, + $results->areMoreResults() ? null : 0 + ); + } else { + $tags = $this->tags + ->with($include, $actor) + ->whereVisibleTo($actor) + ->withStateFor($actor) + ->get(); + } + + return $tags; } } diff --git a/extensions/tags/src/Search/Gambit/FulltextGambit.php b/extensions/tags/src/Search/Gambit/FulltextGambit.php new file mode 100644 index 0000000000..9cf7c99ed2 --- /dev/null +++ b/extensions/tags/src/Search/Gambit/FulltextGambit.php @@ -0,0 +1,48 @@ +tags = $tags; + } + + private function getTagSearchSubQuery(string $searchValue): Builder + { + return $this->tags + ->query() + ->select('id') + ->where('name', 'like', "$searchValue%") + ->orWhere('slug', 'like', "$searchValue%"); + } + + public function apply(SearchState $search, $searchValue) + { + $search->getQuery() + ->whereIn( + 'id', + $this->getTagSearchSubQuery($searchValue) + ); + + return true; + } +} diff --git a/extensions/tags/src/Search/TagSearcher.php b/extensions/tags/src/Search/TagSearcher.php new file mode 100644 index 0000000000..2c1666432b --- /dev/null +++ b/extensions/tags/src/Search/TagSearcher.php @@ -0,0 +1,36 @@ +tags = $tags; + } + + protected function getQuery(User $actor): Builder + { + return $this->tags->query()->whereVisibleTo($actor); + } +} diff --git a/extensions/tags/src/TagRepository.php b/extensions/tags/src/TagRepository.php index 3997d28500..50f8a9bcb6 100644 --- a/extensions/tags/src/TagRepository.php +++ b/extensions/tags/src/TagRepository.php @@ -26,6 +26,11 @@ public function query() return Tag::query(); } + public function queryVisibleTo(User $actor): Builder + { + return $this->scopeVisibleTo($this->query(), $actor); + } + /** * @param array|string $relations * @param User $actor diff --git a/extensions/tags/tests/integration/api/tags/ListWithFulltextSearchTest.php b/extensions/tags/tests/integration/api/tags/ListWithFulltextSearchTest.php new file mode 100644 index 0000000000..059e15da5e --- /dev/null +++ b/extensions/tags/tests/integration/api/tags/ListWithFulltextSearchTest.php @@ -0,0 +1,81 @@ +extension('flarum-tags'); + + $this->prepareDatabase([ + 'tags' => [ + ['id' => 2, 'name' => 'Acme', 'slug' => 'acme'], + ['id' => 3, 'name' => 'Test', 'slug' => 'test'], + ['id' => 4, 'name' => 'Tag', 'slug' => 'tag'], + ['id' => 5, 'name' => 'Franz', 'slug' => 'franz'], + ['id' => 6, 'name' => 'Software', 'slug' => 'software'], + ['id' => 7, 'name' => 'Laravel', 'slug' => 'laravel'], + ['id' => 8, 'name' => 'Flarum', 'slug' => 'flarum'], + ['id' => 9, 'name' => 'Tea', 'slug' => 'tea'], + ['id' => 10, 'name' => 'Access', 'slug' => 'access'], + ], + ]); + } + + /** + * @dataProvider searchDataProvider + * @test + */ + public function can_search_for_tags(string $search, array $expected) + { + $response = $this->send( + $this->request('GET', '/api/tags')->withQueryParams([ + 'filter' => [ + 'q' => $search, + ], + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expected, Arr::pluck($data, 'id')); + } + + public function searchDataProvider(): array + { + return [ + ['fla', [8]], + ['flarum', [8]], + ['flarums', []], + ['a', [2, 10]], + ['ac', [2, 10]], + ['ace', []], + ['acm', [2]], + ['acmes', []], + ['t', [3, 4, 9]], + ['te', [3, 9]], + ['test', [3]], + ['tag', [4]], + ['franz', [5]], + ['software', [6]], + ['lar', [7]], + ['laravel', [7]], + ['tea', [9]], + ['access', [10]], + ]; + } +} diff --git a/framework/core/js/dist-typings/common/extenders/IExtender.d.ts b/framework/core/js/dist-typings/common/extenders/IExtender.d.ts index 43c4f72084..31bcbe4446 100644 --- a/framework/core/js/dist-typings/common/extenders/IExtender.d.ts +++ b/framework/core/js/dist-typings/common/extenders/IExtender.d.ts @@ -3,6 +3,6 @@ export interface IExtensionModule { name: string; exports: unknown; } -export default interface IExtender { - extend(app: Application, extension: IExtensionModule): void; +export default interface IExtender { + extend(app: App, extension: IExtensionModule): void; } diff --git a/framework/core/js/src/common/extenders/IExtender.ts b/framework/core/js/src/common/extenders/IExtender.ts index 12a781e9d7..6fb978b264 100644 --- a/framework/core/js/src/common/extenders/IExtender.ts +++ b/framework/core/js/src/common/extenders/IExtender.ts @@ -5,6 +5,6 @@ export interface IExtensionModule { exports: unknown; } -export default interface IExtender { - extend(app: Application, extension: IExtensionModule): void; +export default interface IExtender { + extend(app: App, extension: IExtensionModule): void; } diff --git a/framework/core/src/Group/GroupRepository.php b/framework/core/src/Group/GroupRepository.php index 50aa2708d8..48975533ee 100644 --- a/framework/core/src/Group/GroupRepository.php +++ b/framework/core/src/Group/GroupRepository.php @@ -41,6 +41,11 @@ public function findOrFail($id, User $actor = null) return $this->scopeVisibleTo($query, $actor)->firstOrFail(); } + public function queryVisibleTo(User $actor = null) + { + return $this->scopeVisibleTo($this->query(), $actor); + } + /** * Scope a query to only include records that are visible to a user. * diff --git a/php-packages/phpstan/src/Extender/Resolver.php b/php-packages/phpstan/src/Extender/Resolver.php index e1e3dcf39b..804c6807d7 100644 --- a/php-packages/phpstan/src/Extender/Resolver.php +++ b/php-packages/phpstan/src/Extender/Resolver.php @@ -68,6 +68,8 @@ private function resolveExtenders(): array } /** + * Retrieves all extenders from a given `extend.php` file. + * * @return Extender[] * @throws ParserErrorsException * @throws \Exception @@ -90,7 +92,22 @@ private function resolveExtendersFromFile($extenderFile): array if ($expression instanceof Array_) { foreach ($expression->items as $item) { if ($item->value instanceof MethodCall) { - $extenders[] = $this->resolveExtender($item->value); + // Conditional extenders + if ($item->value->name->toString() === 'whenExtensionEnabled') { + $conditionalExtenders = $item->value->args[1] ?? null; + + if ($conditionalExtenders->value instanceof Array_) { + foreach ($conditionalExtenders->value->items as $conditionalExtender) { + if ($conditionalExtender->value instanceof MethodCall) { + $extenders[] = $this->resolveExtender($conditionalExtender->value); + } + } + } + } + // Normal extenders + else { + $extenders[] = $this->resolveExtender($item->value); + } } } }