Skip to content

Commit

Permalink
feat(mentions,tags): tag mentions (#3769)
Browse files Browse the repository at this point in the history
* feat: add tag search

Signed-off-by: Sami Mazouz <[email protected]>

* feat(mentions): tag mentions backend

Signed-off-by: Sami Mazouz <[email protected]>

* feat: tag mention design

Signed-off-by: Sami Mazouz <[email protected]>

* refactor: revamp mentions autocomplete

Signed-off-by: Sami Mazouz <[email protected]>

* fix: unauthorized mention of hidden groups

Signed-off-by: Sami Mazouz <[email protected]>

* feat(mentions,tags): use hash format for tag mentions

Signed-off-by: Sami Mazouz <[email protected]>

* refactor: frontend mention format API with mentionable models

Signed-off-by: Sami Mazouz <[email protected]>

* feat: implement tag search on the frontend

Signed-off-by: Sami Mazouz <[email protected]>

* fix: tag color contrast

Signed-off-by: Sami Mazouz <[email protected]>

* fix: tag suggestions styling

Signed-off-by: Sami Mazouz <[email protected]>

* test: works with disabled tags extension

Signed-off-by: Sami Mazouz <[email protected]>

* chore: move `MentionFormats` to `formats`

Signed-off-by: Sami Mazouz <[email protected]>

* fix: mentions preview bad styling

Signed-off-by: Sami Mazouz <[email protected]>

* docs: further migration location clarification

Signed-off-by: Sami Mazouz <[email protected]>

* Apply fixes from StyleCI

* fix: bad test namespace

Signed-off-by: Sami Mazouz <[email protected]>

* fix: phpstan

Signed-off-by: Sami Mazouz <[email protected]>

* fix: conditionally add tag related extenders

Signed-off-by: Sami Mazouz <[email protected]>

* Apply fixes from StyleCI

* feat(phpstan): evaluate conditional extenders

Signed-off-by: Sami Mazouz <[email protected]>

* feat: use mithril routing for tag mentions

Signed-off-by: Sami Mazouz <[email protected]>

---------

Signed-off-by: Sami Mazouz <[email protected]>
Co-authored-by: StyleCI Bot <[email protected]>
  • Loading branch information
SychO9 and StyleCIBot authored Apr 19, 2023
1 parent b868c3d commit 5e28113
Show file tree
Hide file tree
Showing 46 changed files with 1,786 additions and 364 deletions.
4 changes: 4 additions & 0 deletions extensions/mentions/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
"flarum-extension": {
"title": "Mentions",
"category": "feature",
"optional-dependencies": [
"flarum/tags"
],
"icon": {
"name": "fas fa-at",
"backgroundColor": "#539EC1",
Expand Down Expand Up @@ -74,6 +77,7 @@
},
"require-dev": {
"flarum/core": "*@dev",
"flarum/tags": "*@dev",
"flarum/testing": "^1.0.0"
},
"repositories": [
Expand Down
45 changes: 33 additions & 12 deletions extensions/mentions/extend.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
use Flarum\Post\Event\Revised;
use Flarum\Post\Filter\PostFilterer;
use Flarum\Post\Post;
use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\Tags\Tag;
use Flarum\User\User;

return [
Expand All @@ -42,14 +44,14 @@
->render(Formatter\FormatUserMentions::class)
->render(Formatter\FormatGroupMentions::class)
->unparse(Formatter\UnparsePostMentions::class)
->unparse(Formatter\UnparseUserMentions::class)
->parse(Formatter\CheckPermissions::class),
->unparse(Formatter\UnparseUserMentions::class),

(new Extend\Model(Post::class))
->belongsToMany('mentionedBy', Post::class, 'post_mentions_post', 'mentions_post_id', 'post_id')
->belongsToMany('mentionsPosts', Post::class, 'post_mentions_post', 'post_id', 'mentions_post_id')
->belongsToMany('mentionsUsers', User::class, 'post_mentions_user', 'post_id', 'mentions_user_id')
->belongsToMany('mentionsGroups', Group::class, 'post_mentions_group', 'post_id', 'mentions_group_id'),
->belongsToMany('mentionsGroups', Group::class, 'post_mentions_group', 'post_id', 'mentions_group_id')
->belongsToMany('mentionsUsers', User::class, 'post_mentions_user', 'post_id', 'mentions_user_id'),

new Extend\Locales(__DIR__.'/locale'),

Expand Down Expand Up @@ -83,7 +85,7 @@
(new Extend\ApiController(Controller\ListDiscussionsController::class))
->load([
'firstPost.mentionsUsers', 'firstPost.mentionsPosts', 'firstPost.mentionsPosts.user', 'firstPost.mentionsGroups',
'lastPost.mentionsUsers', 'lastPost.mentionsPosts', 'lastPost.mentionsPosts.user', 'lastPost.mentionsGroups'
'lastPost.mentionsUsers', 'lastPost.mentionsPosts', 'lastPost.mentionsPosts.user', 'lastPost.mentionsGroups',
]),

(new Extend\ApiController(Controller\ShowPostController::class))
Expand All @@ -99,12 +101,6 @@
->loadWhere('mentionedBy', [LoadMentionedByRelationship::class, 'mutateRelation'])
->prepareDataForSerialization([LoadMentionedByRelationship::class, 'countRelation']),

(new Extend\ApiController(Controller\CreatePostController::class))
->addOptionalInclude('mentionsGroups'),

(new Extend\ApiController(Controller\UpdatePostController::class))
->addOptionalInclude('mentionsGroups'),

(new Extend\Settings)
->serializeToForum('allowUsernameMentionFormat', 'flarum-mentions.allow_username_format', 'boolval'),

Expand All @@ -121,7 +117,32 @@
->addFilter(Filter\MentionedPostFilter::class),

(new Extend\ApiSerializer(CurrentUserSerializer::class))
->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user, array $attributes): bool {
->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user): bool {
return $user->can('mentionGroups');
})
}),

// Tag mentions
(new Extend\Conditional())
->whenExtensionEnabled('flarum-tags', [
(new Extend\Formatter)
->render(Formatter\FormatTagMentions::class)
->unparse(Formatter\UnparseTagMentions::class),

(new Extend\Model(Post::class))
->belongsToMany('mentionsTags', Tag::class, 'post_mentions_tag', 'post_id', 'mentions_tag_id'),

(new Extend\ApiSerializer(BasicPostSerializer::class))
->hasMany('mentionsTags', TagSerializer::class),

(new Extend\ApiController(Controller\ShowDiscussionController::class))
->load(['posts.mentionsTags']),

(new Extend\ApiController(Controller\ListDiscussionsController::class))
->load([
'firstPost.mentionsTags', 'lastPost.mentionsTags',
]),

(new Extend\ApiController(Controller\ListPostsController::class))
->load(['mentionsTags']),
]),
];
13 changes: 13 additions & 0 deletions extensions/mentions/js/src/@types/shims.d.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
216 changes: 32 additions & 184 deletions extensions/mentions/js/src/forum/addComposerAutocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -57,47 +30,42 @@ export default function addComposerAutocomplete() {
});

extend(TextEditor.prototype, 'buildEditorParams', function (params) {
const searched = [];
let relMentionStart;
let absMentionStart;
let typed;
let matchTyped;

// We store users returned from an API here to preserve order in which they are returned
// This prevents the user list jumping around while users are returned.
// We also use a hashset for user IDs to provide O(1) lookup for the users already in the list.
const returnedUsers = Array.from(app.store.all('users'));
const returnedUserIds = new Set(returnedUsers.map((u) => u.id()));
let mentionables = new MentionableModels({
onmouseenter: function () {
dropdown.setIndex($(this).parent().index());
},
onclick: (replacement) => {
this.attrs.composer.editor.replaceBeforeCursor(absMentionStart - 1, replacement + ' ');

// Store groups, but exclude the two virtual groups - 'Guest' and 'Member'.
const returnedGroups = Array.from(
app.store.all('groups').filter((group) => {
return group.id() != Group.GUEST_ID && group.id() != Group.MEMBER_ID;
})
);

const applySuggestion = (replacement) => {
this.attrs.composer.editor.replaceBeforeCursor(absMentionStart - 1, replacement + ' ');

dropdown.hide();
};
dropdown.hide();
},
});

params.inputListeners.push(() => {
const suggestionsInputListener = () => {
const selection = this.attrs.composer.editor.getSelectionRange();

const cursor = selection[0];

if (selection[1] - cursor > 0) return;

// Search backwards from the cursor for an '@' symbol. If we find one,
// we will want to show the autocomplete dropdown!
// Search backwards from the cursor for a mention triggering symbol. If we find one,
// we will want to show the correct autocomplete dropdown!
// Check classes implementing the IMentionableModel interface to see triggering symbols.
const lastChunk = this.attrs.composer.editor.getLastNChars(30);
absMentionStart = 0;
let activeFormat = null;
for (let i = lastChunk.length - 1; i >= 0; i--) {
const character = lastChunk.substr(i, 1);
if (character === '@' && (i == 0 || /\s/.test(lastChunk.substr(i - 1, 1)))) {
activeFormat = app.mentionFormats.get(character);

if (activeFormat && (i === 0 || /\s/.test(lastChunk.substr(i - 1, 1)))) {
relMentionStart = i + 1;
absMentionStart = cursor - lastChunk.length + i + 1;
mentionables.init(activeFormat.makeMentionables());
break;
}
}
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit 5e28113

Please sign in to comment.