diff --git a/extensions/mentions/composer.json b/extensions/mentions/composer.json
index 00fe6dbe7..741b255cb 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 d2d79ea9f..8c74b5004 100644
--- a/extensions/mentions/extend.php
+++ b/extensions/mentions/extend.php
@@ -26,6 +26,8 @@ use Flarum\Post\Event\Restored;
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 @@ return [
->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 @@ return [
(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 @@ return [
->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 @@ return [
->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 1878233d1..dcb5c31a4 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 261c5f03d..bc8096cad 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;
- })
- );
+ dropdown.hide();
+ },
+ });
- const applySuggestion = (replacement) => {
- this.attrs.composer.editor.replaceBeforeCursor(absMentionStart - 1, replacement + ' ');
-
- 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 3fd8d1db5..57b1b7c59 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 ee22c6773..8c456f189 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 000000000..48e0ffaad
--- /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 000000000..cf2db51f7
--- /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 40910b656..10e606d74 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 000000000..7703db5dd
--- /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 000000000..65b8499e4
--- /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 000000000..2095051fa
--- /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 000000000..2901d9e95
--- /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 000000000..eaa480d9a
--- /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 000000000..e7b3c5be2
--- /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 000000000..8b6485967
--- /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 000000000..2132a62cc
--- /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 000000000..6b95d9d83
--- /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 000000000..f053e6e37
--- /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 6a99ee38e..446ffca84 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 3fdf7fec9..5a2961e77 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 415d99907..f947bf191 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 f427c289e..f05741cea 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 8177e4d1c..d5d9e819f 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 7f47beec2..18a156fe2 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 @@ class ConfigureMentions
$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 @@ class ConfigureMentions
@
';
+
$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 @@ class ConfigureMentions
->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 @@ class ConfigureMentions
$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 @@ class ConfigureMentions
-
+
@
@@ -183,29 +202,130 @@ class ConfigureMentions
';
- $tag->filterChain->prepend([static::class, 'addGroupId'])
- ->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterGroupMentions(tag); }');
- $config->Preg->match('/\B@["|“](?((?!"#[a-z]{0,3}[0-9]+).)+)["|”]#g(?[0-9]+)\b/', $tagName);
+ $tag->filterChain->prepend([static::class, 'addGroupId'])
+ ->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);
}
/**
- * @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 (isset($group) && ! in_array($group->id, [Group::GUEST_ID, Group::MEMBER_ID])) {
+ if ($actor->cannot('mentionGroups') || in_array($id, [Group::GUEST_ID, Group::MEMBER_ID])) {
+ $tag->invalidate();
+
+ return false;
+ }
+
+ $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 000000000..e69de29bb
diff --git a/extensions/mentions/src/Formatter/CheckPermissions.php b/extensions/mentions/src/Formatter/CheckPermissions.php
deleted file mode 100644
index c6a899b45..000000000
--- 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 713166cc1..985d6f321 100644
--- a/extensions/mentions/src/Formatter/FormatGroupMentions.php
+++ b/extensions/mentions/src/Formatter/FormatGroupMentions.php
@@ -39,8 +39,8 @@ class FormatGroupMentions
{
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 000000000..c74c83e69
--- /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 000000000..b2cae82f2
--- /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 fafe2a850..e3a82543a 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 @@ class UpdateMentionsMetadataWhenInvisible
// 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 7a7c4f19c..df0a1af1f 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 @@ class UpdateMentionsMetadataWhenVisible
$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 @@ class UpdateMentionsMetadataWhenVisible
$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 f001ba2bf..f4c60b487 100644
--- a/extensions/mentions/tests/integration/api/GroupMentionsTest.php
+++ b/extensions/mentions/tests/integration/api/GroupMentionsTest.php
@@ -33,40 +33,30 @@ class GroupMentionsTest 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],
],
'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 @@ class GroupMentionsTest extends TestCase
*/
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 @@ class GroupMentionsTest extends TestCase
*/
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 000000000..f478a96d1
--- /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 97a626358..8f2d875c3 100644
--- a/extensions/tags/extend.php
+++ b/extensions/tags/extend.php
@@ -29,6 +29,8 @@ use Flarum\Tags\Listener;
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 @@ return [
(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 533349edf..e5c6e84f7 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 000000000..c802dc707
--- /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 755f718f2..d78b5339e 100644
--- a/extensions/tags/src/Api/Controller/ListTagsController.php
+++ b/extensions/tags/src/Api/Controller/ListTagsController.php
@@ -11,7 +11,10 @@ namespace Flarum\Tags\Api\Controller;
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 @@ class ListTagsController extends AbstractListController
{
$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 000000000..9cf7c99ed
--- /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 000000000..2c1666432
--- /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 3997d2850..50f8a9bcb 100644
--- a/extensions/tags/src/TagRepository.php
+++ b/extensions/tags/src/TagRepository.php
@@ -26,6 +26,11 @@ class TagRepository
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 000000000..059e15da5
--- /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 43c4f7208..31bcbe444 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 12a781e9d..6fb978b26 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 50aa2708d..48975533e 100644
--- a/framework/core/src/Group/GroupRepository.php
+++ b/framework/core/src/Group/GroupRepository.php
@@ -41,6 +41,11 @@ class GroupRepository
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 e1e3dcf39..804c6807d 100644
--- a/php-packages/phpstan/src/Extender/Resolver.php
+++ b/php-packages/phpstan/src/Extender/Resolver.php
@@ -68,6 +68,8 @@ class Resolver
}
/**
+ * Retrieves all extenders from a given `extend.php` file.
+ *
* @return Extender[]
* @throws ParserErrorsException
* @throws \Exception
@@ -90,7 +92,22 @@ class Resolver
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);
+ }
}
}
}