mirror of
https://github.com/flarum/core.git
synced 2025-08-01 14:10:37 +02:00
feat(mentions,tags): tag mentions (#3769)
* feat: add tag search Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * feat(mentions): tag mentions backend Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * feat: tag mention design Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * refactor: revamp mentions autocomplete Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * fix: unauthorized mention of hidden groups Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * feat(mentions,tags): use hash format for tag mentions Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * refactor: frontend mention format API with mentionable models Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * feat: implement tag search on the frontend Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * fix: tag color contrast Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * fix: tag suggestions styling Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * test: works with disabled tags extension Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * chore: move `MentionFormats` to `formats` Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * fix: mentions preview bad styling Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * docs: further migration location clarification Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * Apply fixes from StyleCI * fix: bad test namespace Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * fix: phpstan Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * fix: conditionally add tag related extenders Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * Apply fixes from StyleCI * feat(phpstan): evaluate conditional extenders Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> * feat: use mithril routing for tag mentions Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> --------- Signed-off-by: Sami Mazouz <sychocouldy@gmail.com> Co-authored-by: StyleCI Bot <bot@styleci.io>
This commit is contained in:
@@ -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": [
|
||||
|
@@ -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']),
|
||||
]),
|
||||
];
|
||||
|
13
extensions/mentions/js/src/@types/shims.d.ts
vendored
13
extensions/mentions/js/src/@types/shims.d.ts
vendored
@@ -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;
|
||||
|
@@ -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 = $('<div class="ComposerBody-mentionsDropdownContainer"></div>');
|
||||
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 (
|
||||
<button
|
||||
className={'PostPreview ' + className}
|
||||
onclick={() => applySuggestion(replacement)}
|
||||
onmouseenter={function () {
|
||||
dropdown.setIndex($(this).parent().index());
|
||||
}}
|
||||
>
|
||||
<span className="PostPreview-content">
|
||||
{avatar(user)}
|
||||
{username} {content}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const makeGroupSuggestion = function (group, replacement, content, className = '') {
|
||||
let groupName = group.namePlural().toLowerCase();
|
||||
|
||||
if (typed) {
|
||||
groupName = highlight(groupName, typed);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={'PostPreview ' + className}
|
||||
onclick={() => applySuggestion(replacement)}
|
||||
onmouseenter={function () {
|
||||
dropdown.setIndex($(this).parent().index());
|
||||
}}
|
||||
>
|
||||
<span className="PostPreview-content">
|
||||
<Badge class={`Avatar Badge Badge--group--${group.id()} Badge-icon `} color={group.color()} type="group" icon={group.icon()} />
|
||||
<span className="username">{groupName}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const userMatches = function (user) {
|
||||
const names = [user.username(), user.displayName()];
|
||||
|
||||
return names.some((name) => name.toLowerCase().substr(0, typed.length) === typed);
|
||||
};
|
||||
|
||||
const groupMatches = function (group) {
|
||||
const names = [group.nameSingular(), group.namePlural()];
|
||||
|
||||
return names.some((name) => name.toLowerCase().substr(0, typed.length) === typed);
|
||||
};
|
||||
const 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) {
|
||||
|
@@ -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);
|
||||
|
@@ -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,
|
||||
};
|
||||
|
@@ -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<CustomAttrs extends IMentionsDropdownItemAttrs> extends Component<CustomAttrs> {
|
||||
view(vnode: Mithril.Vnode<CustomAttrs>): Mithril.Children {
|
||||
const { mentionable, ...attrs } = this.attrs;
|
||||
|
||||
const className = classList('MentionsDropdownItem', 'PostPreview', `MentionsDropdown-${mentionable.type()}`);
|
||||
|
||||
return (
|
||||
<button className={className} {...attrs}>
|
||||
<span className="PostPreview-content">{vnode.children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
54
extensions/mentions/js/src/forum/extenders/Mentionables.ts
Normal file
54
extensions/mentions/js/src/forum/extenders/Mentionables.ts
Normal file
@@ -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<ForumApplication> {
|
||||
protected formats: (new () => MentionFormat)[] = [];
|
||||
protected mentionables: Record<string, (new () => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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')));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -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<Group, AtMentionFormat> {
|
||||
type(): string {
|
||||
return 'group';
|
||||
}
|
||||
|
||||
initialResults(): Group[] {
|
||||
return Array.from(
|
||||
app.store.all<Group>('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 <caption>Group mention</caption>
|
||||
* // '@"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 (
|
||||
<>
|
||||
<Badge className={`Avatar Badge Badge--group--${model.id()} Badge-icon`} color={model.color()} type="group" icon={model.icon()} />
|
||||
<span className="username">{groupName}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<Group[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
enabled(): boolean {
|
||||
return app.session?.user?.canMentionGroups() ?? false;
|
||||
}
|
||||
}
|
@@ -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<M extends Model = Model, Format extends MentionFormat = MentionFormat> {
|
||||
public format: Format;
|
||||
|
||||
public constructor(format: Format) {
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
abstract type(): string;
|
||||
abstract initialResults(): M[];
|
||||
abstract search(typed: string): Promise<M[]>;
|
||||
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;
|
||||
}
|
@@ -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<string, Map<string, Model>> = {};
|
||||
public typed: string | null = null;
|
||||
private searched: string[] = [];
|
||||
private dropdownItemAttrs: Record<string, any> = {};
|
||||
|
||||
constructor(dropdownItemAttrs: Record<string, any>) {
|
||||
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<void> => {
|
||||
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 (
|
||||
<MentionsDropdownItem mentionable={mentionable} onclick={() => onclick(replacement)} {...attrs}>
|
||||
{content}
|
||||
</MentionsDropdownItem>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
102
extensions/mentions/js/src/forum/mentionables/PostMention.tsx
Normal file
102
extensions/mentions/js/src/forum/mentionables/PostMention.tsx
Normal file
@@ -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<Post, AtMentionFormat> {
|
||||
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 <caption>Post mention</caption>
|
||||
* // '@"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<Post[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
enabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
65
extensions/mentions/js/src/forum/mentionables/TagMention.tsx
Normal file
65
extensions/mentions/js/src/forum/mentionables/TagMention.tsx
Normal file
@@ -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<Tag, HashMentionFormat> {
|
||||
type(): string {
|
||||
return 'tag';
|
||||
}
|
||||
|
||||
initialResults(): Tag[] {
|
||||
return Array.from(app.store.all<Tag>('tags'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the mention syntax for a tag mention.
|
||||
*
|
||||
* ~tagSlug
|
||||
*
|
||||
* @example <caption>Tag mention</caption>
|
||||
* // ~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<Tag[]> {
|
||||
return await app.store.find<Tag[]>('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 (
|
||||
<>
|
||||
<Badge className="Avatar" icon={model.icon()} color={model.color()} type="tag" />
|
||||
<span className="username">{tagName}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
enabled(): boolean {
|
||||
return 'flarum-tags' in flarum.extensions;
|
||||
}
|
||||
}
|
@@ -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<User, AtMentionFormat> {
|
||||
type(): string {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
initialResults(): User[] {
|
||||
return Array.from(app.store.all<User>('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 <caption>New display name syntax</caption>
|
||||
* // '@"user"#1'
|
||||
* forUser(User) // User is ID 1, display name is 'User'
|
||||
*
|
||||
* @example <caption>Using old syntax</caption>
|
||||
* // '@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<User[]> {
|
||||
return await app.store.find<User[]>('users', { filter: { q: typed }, page: { limit: 5 } });
|
||||
}
|
||||
|
||||
enabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
@@ -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'];
|
||||
}
|
||||
}
|
@@ -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}`;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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());
|
||||
}
|
||||
}
|
@@ -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 <caption>New display name syntax</caption>
|
||||
* // '@"User"#1'
|
||||
* getMentionText(User) // User is ID 1, display name is 'User'
|
||||
*
|
||||
* @example <caption>Replying</caption>
|
||||
* // '@"User"#p13'
|
||||
* getMentionText(User, 13) // User display name is 'User', post ID is 13
|
||||
*
|
||||
* @example <caption>Using old syntax</caption>
|
||||
* // '@username'
|
||||
* getMentionText(User) // User's username is 'username'
|
||||
*
|
||||
* @example <caption>Group mention</caption>
|
||||
* // '@"Mods"#g4'
|
||||
* getMentionText(undefined, undefined, group) // Group display name is 'Mods', group ID is 4
|
||||
* @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';
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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.
|
||||
|
@@ -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%;
|
||||
|
@@ -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
|
||||
<span class="UserMention UserMention--deleted">@<xsl:value-of select="@displayname"/></span>
|
||||
</xsl:otherwise>
|
||||
</xsl:choose>';
|
||||
|
||||
$tag->filterChain->prepend([static::class, 'addUserId'])
|
||||
->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterUserMentions(tag); }');
|
||||
|
||||
$config->Preg->match('/\B@["|“](?<displayname>((?!"#[a-z]{0,3}[0-9]+).)+)["|”]#(?<id>[0-9]+)\b/', $tagName);
|
||||
$tag->filterChain->append([static::class, 'dummyFilter'])
|
||||
->setJs('function(tag) { return flarum.extensions["flarum-mentions"].postFilterUserMentions(tag); }');
|
||||
|
||||
$config->Preg->match('/\B@["“](?<displayname>((?!"#[a-z]{0,3}[0-9]+).)+)["”]#(?<id>[0-9]+)\b/', $tagName);
|
||||
$config->Preg->match('/\B@(?<username>[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@["|“](?<displayname>((?!"#[a-z]{0,3}[0-9]+).)+)["|”]#p(?<id>[0-9]+)\b/', $tagName);
|
||||
$tag->filterChain->append([static::class, 'dummyFilter'])
|
||||
->setJs('function(tag) { return flarum.extensions["flarum-mentions"].postFilterPostMentions(tag); }');
|
||||
|
||||
$config->Preg->match('/\B@["“](?<displayname>((?!"#[a-z]{0,3}[0-9]+).)+)["”]#p(?<id>[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
|
||||
<xsl:when test="@deleted != 1">
|
||||
<xsl:choose>
|
||||
<xsl:when test="string(@color) != \'\'">
|
||||
<span class="GroupMention GroupMention--colored" style="--group-color:{@color};">
|
||||
<span class="GroupMention GroupMention--colored" style="--color:{@color};">
|
||||
<span class="GroupMention-name">@<xsl:value-of select="@groupname"/></span>
|
||||
<xsl:if test="string(@icon) != \'\'">
|
||||
<i class="icon {@icon}"></i>
|
||||
@@ -183,29 +202,130 @@ class ConfigureMentions
|
||||
</span>
|
||||
</xsl:otherwise>
|
||||
</xsl:choose>';
|
||||
$tag->filterChain->prepend([static::class, 'addGroupId'])
|
||||
->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterGroupMentions(tag); }');
|
||||
|
||||
$config->Preg->match('/\B@["|“](?<groupname>((?!"#[a-z]{0,3}[0-9]+).)+)["|”]#g(?<id>[0-9]+)\b/', $tagName);
|
||||
$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@["“](?<groupname>((?!"#[a-z]{0,3}[0-9]+).)+)["|”]#g(?<id>[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 = '
|
||||
<xsl:choose>
|
||||
<xsl:when test="@deleted != 1">
|
||||
<a href="{$TAG_URL}{@slug}" data-id="{@id}">
|
||||
<xsl:attribute name="class">
|
||||
<xsl:choose>
|
||||
<xsl:when test="@color != \'\'">
|
||||
<xsl:text>TagMention TagMention--colored</xsl:text>
|
||||
</xsl:when>
|
||||
<xsl:otherwise>
|
||||
<xsl:text>TagMention</xsl:text>
|
||||
</xsl:otherwise>
|
||||
</xsl:choose>
|
||||
</xsl:attribute>
|
||||
<xsl:attribute name="style">
|
||||
<xsl:choose>
|
||||
<xsl:when test="@color != \'\'">
|
||||
<xsl:text>--color:</xsl:text>
|
||||
<xsl:value-of select="@color"/>
|
||||
</xsl:when>
|
||||
</xsl:choose>
|
||||
</xsl:attribute>
|
||||
<span class="TagMention-text">
|
||||
<xsl:if test="@icon != \'\'">
|
||||
<i class="icon {@icon}"></i>
|
||||
</xsl:if>
|
||||
<xsl:value-of select="@tagname"/>
|
||||
</span>
|
||||
</a>
|
||||
</xsl:when>
|
||||
<xsl:otherwise>
|
||||
<span class="TagMention TagMention--deleted" data-id="{@id}">
|
||||
<span class="TagMention-text">
|
||||
<xsl:value-of select="@tagname"/>
|
||||
</span>
|
||||
</span>
|
||||
</xsl:otherwise>
|
||||
</xsl:choose>';
|
||||
|
||||
$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#(?<slug>[-_\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;
|
||||
}
|
||||
}
|
||||
|
0
extensions/mentions/src/FilterVisiblePosts.php
Executable file
0
extensions/mentions/src/FilterVisiblePosts.php
Executable file
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mentions\Formatter;
|
||||
|
||||
use Flarum\User\User;
|
||||
use s9e\TextFormatter\Parser;
|
||||
|
||||
class CheckPermissions
|
||||
{
|
||||
public function __invoke(Parser $parser, $content, string $text, ?User $actor): string
|
||||
{
|
||||
// Check user has `mentionGroups` permission, if not, remove the `GROUPMENTION` tag from the parser.
|
||||
if ($actor && $actor->cannot('mentionGroups')) {
|
||||
$parser->disableTag('GROUPMENTION');
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
41
extensions/mentions/src/Formatter/FormatTagMentions.php
Normal file
41
extensions/mentions/src/Formatter/FormatTagMentions.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mentions\Formatter;
|
||||
|
||||
use Flarum\Post\Post;
|
||||
use Flarum\Tags\Tag;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use s9e\TextFormatter\Renderer;
|
||||
use s9e\TextFormatter\Utils;
|
||||
|
||||
class FormatTagMentions
|
||||
{
|
||||
public function __invoke(Renderer $renderer, $context, ?string $xml, Request $request = null): string
|
||||
{
|
||||
return Utils::replaceAttributes($xml, 'TAGMENTION', function ($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['deleted'] = false;
|
||||
$attributes['tagname'] = $tag->name;
|
||||
$attributes['slug'] = $tag->slug;
|
||||
$attributes['color'] = $tag->color ?? '';
|
||||
$attributes['icon'] = $tag->icon ?? '';
|
||||
} else {
|
||||
$attributes['deleted'] = true;
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
});
|
||||
}
|
||||
}
|
77
extensions/mentions/src/Formatter/UnparseTagMentions.php
Normal file
77
extensions/mentions/src/Formatter/UnparseTagMentions.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mentions\Formatter;
|
||||
|
||||
use Flarum\Post\Post;
|
||||
use Flarum\Tags\Tag;
|
||||
use s9e\TextFormatter\Utils;
|
||||
|
||||
class UnparseTagMentions
|
||||
{
|
||||
/**
|
||||
* Configure rendering for user mentions.
|
||||
*
|
||||
* @param string $xml
|
||||
* @param mixed $context
|
||||
* @return string $xml to be unparsed
|
||||
*/
|
||||
public function __invoke($context, string $xml)
|
||||
{
|
||||
$xml = $this->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
|
||||
);
|
||||
}
|
||||
}
|
@@ -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([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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');
|
||||
}
|
||||
}
|
||||
|
@@ -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' => '<r><p>One of the <GROUPMENTION color="#80349E" groupname="Mods" icon="fas fa-bolt" id="4">@"Mods"#g4</GROUPMENTION> will look at this</p></r>'],
|
||||
['id' => 6, 'number' => 3, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><p><GROUPMENTION color="#80349E" groupname="OldGroupName" icon="fas fa-circle" id="100">@"OldGroupName"#g100</GROUPMENTION></p></r>'],
|
||||
['id' => 7, 'number' => 4, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><p><GROUPMENTION color="#000" groupname="OldGroupName" icon="fas fa-circle" id="11">@"OldGroupName"#g11</GROUPMENTION></p></r>'],
|
||||
['id' => 4, 'number' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><p>One of the <GROUPMENTION groupname="Mods" id="4">@"Mods"#g4</GROUPMENTION> will look at this</p></r>'],
|
||||
['id' => 6, 'number' => 3, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><p><GROUPMENTION groupname="OldGroupName" id="100">@"OldGroupName"#g100</GROUPMENTION></p></r>'],
|
||||
['id' => 7, 'number' => 4, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><p><GROUPMENTION groupname="OldGroupName" id="11">@"OldGroupName"#g11</GROUPMENTION></p></r>'],
|
||||
],
|
||||
'post_mentions_group' => [
|
||||
['post_id' => 4, 'mentions_group_id' => 4],
|
||||
['post_id' => 7, 'mentions_group_id' => 11],
|
||||
],
|
||||
'group_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' => [
|
||||
|
385
extensions/mentions/tests/integration/api/TagMentionsTest.php
Normal file
385
extensions/mentions/tests/integration/api/TagMentionsTest.php
Normal file
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mentions\Tests\integration\api;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Group\Group;
|
||||
use Flarum\Post\CommentPost;
|
||||
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||
use Flarum\Testing\integration\TestCase;
|
||||
|
||||
class TagMentionsTest extends TestCase
|
||||
{
|
||||
use RetrievesAuthorizedUsers;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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' => '<r><TAGMENTION id="1" slug="test_old_slug" tagname="TestOldName">#test_old_slug</TAGMENTION></r>'],
|
||||
['id' => 7, 'number' => 5, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 2021, 'type' => 'comment', 'content' => '<r><TAGMENTION id="3" slug="support" tagname="Support">#deleted_relation</TAGMENTION></r>'],
|
||||
['id' => 8, 'number' => 6, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><TAGMENTION id="2020" slug="i_am_a_deleted_tag" tagname="i_am_a_deleted_tag">#i_am_a_deleted_tag</TAGMENTION></r>'],
|
||||
['id' => 10, 'number' => 11, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><TAGMENTION id="5" slug="laravel">#laravel</TAGMENTION></r>'],
|
||||
],
|
||||
'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));
|
||||
}
|
||||
}
|
@@ -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),
|
||||
];
|
||||
|
@@ -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));
|
||||
|
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
use Flarum\Database\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
|
||||
/**
|
||||
* Why does this migration reside here rather than in the mentions extension?
|
||||
*
|
||||
* To be able to use a foreign key constraint on the `mentions_tag_id` column,
|
||||
* we need to create the `post_mentions_tag` table after the `tags` table has
|
||||
* been created. This is not possible in the mentions extension, because the
|
||||
* tags extension is not always enabled.
|
||||
*
|
||||
* Other solutions such as conditional migrations have more implications and
|
||||
* require more changes to the process of enabling/disabling extensions.
|
||||
* @link https://github.com/flarum/framework/pull/3689
|
||||
*
|
||||
* This is a temporary solution until we implement mentionable models in v2.0.
|
||||
* @link https://github.com/orgs/flarum/projects/22/views/1?pane=issue&itemId=21752599
|
||||
*
|
||||
* At the same time, because of https://github.com/flarum/issue-archive/issues/44,
|
||||
* we need a way to track which tags are referenced in a tag change event post,
|
||||
* so this might actually be a permanent solution.
|
||||
*/
|
||||
return Migration::createTable(
|
||||
'post_mentions_tag',
|
||||
function (Blueprint $table) {
|
||||
$table->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']);
|
||||
}
|
||||
);
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
48
extensions/tags/src/Search/Gambit/FulltextGambit.php
Normal file
48
extensions/tags/src/Search/Gambit/FulltextGambit.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Search\Gambit;
|
||||
|
||||
use Flarum\Search\GambitInterface;
|
||||
use Flarum\Search\SearchState;
|
||||
use Flarum\Tags\TagRepository;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class FulltextGambit implements GambitInterface
|
||||
{
|
||||
/**
|
||||
* @var TagRepository
|
||||
*/
|
||||
protected $tags;
|
||||
|
||||
public function __construct(TagRepository $tags)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
36
extensions/tags/src/Search/TagSearcher.php
Normal file
36
extensions/tags/src/Search/TagSearcher.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Search;
|
||||
|
||||
use Flarum\Search\AbstractSearcher;
|
||||
use Flarum\Search\GambitManager;
|
||||
use Flarum\Tags\TagRepository;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class TagSearcher extends AbstractSearcher
|
||||
{
|
||||
/**
|
||||
* @var TagRepository
|
||||
*/
|
||||
protected $tags;
|
||||
|
||||
public function __construct(TagRepository $tags, GambitManager $gambits, array $searchMutators)
|
||||
{
|
||||
parent::__construct($gambits, $searchMutators);
|
||||
|
||||
$this->tags = $tags;
|
||||
}
|
||||
|
||||
protected function getQuery(User $actor): Builder
|
||||
{
|
||||
return $this->tags->query()->whereVisibleTo($actor);
|
||||
}
|
||||
}
|
@@ -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
|
||||
|
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Tests\integration\api\tags;
|
||||
|
||||
use Flarum\Testing\integration\TestCase;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ListWithFulltextSearchTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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]],
|
||||
];
|
||||
}
|
||||
}
|
4
framework/core/js/dist-typings/common/extenders/IExtender.d.ts
generated
vendored
4
framework/core/js/dist-typings/common/extenders/IExtender.d.ts
generated
vendored
@@ -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<App = Application> {
|
||||
extend(app: App, extension: IExtensionModule): void;
|
||||
}
|
||||
|
@@ -5,6 +5,6 @@ export interface IExtensionModule {
|
||||
exports: unknown;
|
||||
}
|
||||
|
||||
export default interface IExtender {
|
||||
extend(app: Application, extension: IExtensionModule): void;
|
||||
export default interface IExtender<App = Application> {
|
||||
extend(app: App, extension: IExtensionModule): void;
|
||||
}
|
||||
|
@@ -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.
|
||||
*
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user