mirror of
https://github.com/flarum/framework.git
synced 2024-11-29 12:43:52 +08:00
Minor refactors in frontend JS (#69)
* Add Prettier * Update dependencies; add typescript setup * Add tsconfig * Rewrite some mentions frontend into TS * Fix use of username instead of display name * Change back to JS * Remove commented code * Update function name to match filename * Update getMentionText.js * Simplify condition * Bump packages to stable versions; use prettier package * Make functions use camel case * Update js/package.json Co-authored-by: Sami Mazouz <sychocouldy@gmail.com> * Don't access data directly * Update js/src/forum/addComposerAutocomplete.js Co-authored-by: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com> * Update tsconfig.json Co-authored-by: Sami Mazouz <sychocouldy@gmail.com> Co-authored-by: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com>
This commit is contained in:
parent
6a5d7e9864
commit
6173c3d612
6450
extensions/mentions/js/package-lock.json
generated
6450
extensions/mentions/js/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
@ -1,13 +1,20 @@
|
|||
{
|
||||
"private": true,
|
||||
"name": "@flarum/mentions",
|
||||
"prettier": "@flarum/prettier-config",
|
||||
"dependencies": {
|
||||
"flarum-webpack-config": "0.1.0-beta.10",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-cli": "^3.3.11"
|
||||
"flarum-webpack-config": "^1.0.0",
|
||||
"webpack": "^4.46.0",
|
||||
"webpack-cli": "^4.7.2"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "webpack --mode development --watch",
|
||||
"build": "webpack --mode production"
|
||||
"build": "webpack --mode production",
|
||||
"format": "prettier --write src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flarum/prettier-config": "^1.0.0",
|
||||
"@types/mithril": "^2.0.8",
|
||||
"flarum-tsconfig": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,22 +1,42 @@
|
|||
import { extend } from 'flarum/extend';
|
||||
import TextEditor from 'flarum/components/TextEditor';
|
||||
import TextEditorButton from 'flarum/components/TextEditorButton';
|
||||
import ReplyComposer from 'flarum/components/ReplyComposer';
|
||||
import EditPostComposer from 'flarum/components/EditPostComposer';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import usernameHelper from 'flarum/helpers/username';
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
import KeyboardNavigatable from 'flarum/utils/KeyboardNavigatable';
|
||||
import { truncate } from 'flarum/utils/string';
|
||||
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/forum/utils/KeyboardNavigatable';
|
||||
import { truncate } from 'flarum/common/utils/string';
|
||||
import { throttle } from 'flarum/common/utils/throttleDebounce';
|
||||
|
||||
import AutocompleteDropdown from './fragments/AutocompleteDropdown';
|
||||
import cleanDisplayName from './utils/cleanDisplayName';
|
||||
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);
|
||||
}
|
||||
});
|
||||
if (dropdown.active) buildSuggestions();
|
||||
});
|
||||
searched.push(typedLower);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default function addComposerAutocomplete() {
|
||||
const $container = $('<div class="ComposerBody-mentionsDropdownContainer"></div>');
|
||||
const dropdown = new AutocompleteDropdown();
|
||||
|
||||
extend(TextEditor.prototype, 'oncreate', function (params) {
|
||||
extend(TextEditor.prototype, 'oncreate', function () {
|
||||
const $editor = this.$('.TextEditor-editor').wrap('<div class="ComposerBody-mentionsWrapper"></div>');
|
||||
|
||||
this.navigator = new KeyboardNavigatable();
|
||||
|
@ -37,13 +57,12 @@ export default function addComposerAutocomplete() {
|
|||
let absMentionStart;
|
||||
let typed;
|
||||
let matchTyped;
|
||||
let searchTimeout;
|
||||
|
||||
// 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()));
|
||||
const returnedUserIds = new Set(returnedUsers.map((u) => u.id()));
|
||||
|
||||
const applySuggestion = (replacement) => {
|
||||
app.composer.editor.replaceBeforeCursor(absMentionStart - 1, replacement + ' ');
|
||||
|
@ -51,177 +70,172 @@ export default function addComposerAutocomplete() {
|
|||
dropdown.hide();
|
||||
};
|
||||
|
||||
params.inputListeners.push(function(e) {
|
||||
const selection = app.composer.editor.getSelectionRange();
|
||||
params.inputListeners.push(function () {
|
||||
const selection = app.composer.editor.getSelectionRange();
|
||||
|
||||
const cursor = selection[0];
|
||||
const cursor = selection[0];
|
||||
|
||||
if (selection[1] - cursor > 0) return;
|
||||
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!
|
||||
const lastChunk = app.composer.editor.getLastNChars(30);
|
||||
absMentionStart = 0;
|
||||
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)))) {
|
||||
relMentionStart = i + 1;
|
||||
absMentionStart = cursor - lastChunk.length + i + 1;
|
||||
break;
|
||||
}
|
||||
// Search backwards from the cursor for an '@' symbol. If we find one,
|
||||
// we will want to show the autocomplete dropdown!
|
||||
const lastChunk = app.composer.editor.getLastNChars(30);
|
||||
absMentionStart = 0;
|
||||
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)))) {
|
||||
relMentionStart = i + 1;
|
||||
absMentionStart = cursor - lastChunk.length + i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dropdown.hide();
|
||||
dropdown.active = false;
|
||||
dropdown.hide();
|
||||
dropdown.active = false;
|
||||
|
||||
if (absMentionStart) {
|
||||
typed = lastChunk.substring(relMentionStart).toLowerCase();
|
||||
matchTyped = typed.match(/^["|“]((?:(?!"#).)+)$/);
|
||||
typed = (matchTyped && matchTyped[1]) || typed;
|
||||
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;
|
||||
}
|
||||
const makeSuggestion = function (user, replacement, content, className = '') {
|
||||
const username = usernameHelper(user);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
if (typed) {
|
||||
username.children = [highlight(username.text, typed)];
|
||||
delete username.text;
|
||||
}
|
||||
|
||||
const userMatches = function(user) {
|
||||
const names = [
|
||||
user.username(),
|
||||
user.displayName()
|
||||
];
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
return names.some(name => name.toLowerCase().substr(0, typed.length) === typed);
|
||||
};
|
||||
const userMatches = function (user) {
|
||||
const names = [user.username(), user.displayName()];
|
||||
|
||||
const buildSuggestions = () => {
|
||||
const suggestions = [];
|
||||
return names.some((name) => name.toLowerCase().substr(0, typed.length) === typed);
|
||||
};
|
||||
|
||||
// If the user has started to type a username, then suggest users
|
||||
// matching that username.
|
||||
if (typed) {
|
||||
returnedUsers.forEach(user => {
|
||||
if (!userMatches(user)) return;
|
||||
const buildSuggestions = () => {
|
||||
const suggestions = [];
|
||||
|
||||
suggestions.push(
|
||||
makeSuggestion(user, `@"${cleanDisplayName(user)}"#${user.id()}`, '', 'MentionsDropdown-user')
|
||||
);
|
||||
});
|
||||
}
|
||||
// If the user has started to type a username, then suggest users
|
||||
// matching that username.
|
||||
if (typed) {
|
||||
returnedUsers.forEach((user) => {
|
||||
if (!userMatches(user)) return;
|
||||
|
||||
// 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 (app.composer.bodyMatches(ReplyComposer) || app.composer.bodyMatches(EditPostComposer)) {
|
||||
const composerAttrs = app.composer.body.attrs;
|
||||
const composerPost = composerAttrs.post;
|
||||
const discussion = (composerPost && composerPost.discussion()) || composerAttrs.discussion;
|
||||
suggestions.push(makeSuggestion(user, getMentionText(user), '', 'MentionsDropdown-user'));
|
||||
});
|
||||
}
|
||||
|
||||
if (discussion) {
|
||||
discussion.posts()
|
||||
.filter(post => post && post.contentType() === 'comment' && (!composerPost || post.number() < composerPost.number()))
|
||||
.sort((a, b) => b.createdAt() - a.createdAt())
|
||||
.filter(post => {
|
||||
const user = post.user();
|
||||
return user && userMatches(user);
|
||||
})
|
||||
.splice(0, 5)
|
||||
.forEach(post => {
|
||||
const user = post.user();
|
||||
suggestions.push(
|
||||
makeSuggestion(user, `@"${cleanDisplayName(user)}"#p${post.id()}`, [
|
||||
app.translator.trans('flarum-mentions.forum.composer.reply_to_post_text', {number: post.number()}), ' — ',
|
||||
truncate(post.contentPlain(), 200)
|
||||
], 'MentionsDropdown-post')
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
// 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 (app.composer.bodyMatches(ReplyComposer) || app.composer.bodyMatches(EditPostComposer)) {
|
||||
const composerAttrs = app.composer.body.attrs;
|
||||
const composerPost = composerAttrs.post;
|
||||
const discussion = (composerPost && composerPost.discussion()) || composerAttrs.discussion;
|
||||
|
||||
if (suggestions.length) {
|
||||
dropdown.items = suggestions;
|
||||
m.render($container[0], dropdown.render());
|
||||
|
||||
dropdown.show();
|
||||
const coordinates = app.composer.editor.getCaretCoordinates(absMentionStart);
|
||||
const width = dropdown.$().outerWidth();
|
||||
const height = dropdown.$().outerHeight();
|
||||
const parent = dropdown.$().offsetParent();
|
||||
let left = coordinates.left;
|
||||
let top = coordinates.top + 15;
|
||||
|
||||
// Keep the dropdown inside the editor.
|
||||
if (top + height > parent.height()) {
|
||||
top = coordinates.top - height - 15;
|
||||
}
|
||||
if (left + width > parent.width()) {
|
||||
left = parent.width() - width;
|
||||
}
|
||||
|
||||
// Prevent the dropdown from going off screen on mobile
|
||||
top = Math.max(-(parent.offset().top - $(document).scrollTop()), top);
|
||||
left = Math.max(-parent.offset().left, left);
|
||||
|
||||
dropdown.show(left, top);
|
||||
} else {
|
||||
dropdown.active = false;
|
||||
dropdown.hide();
|
||||
}
|
||||
};
|
||||
|
||||
dropdown.active = true;
|
||||
|
||||
buildSuggestions();
|
||||
|
||||
dropdown.setIndex(0);
|
||||
dropdown.$().scrollTop(0);
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
// 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) {
|
||||
searchTimeout = setTimeout(function() {
|
||||
const typedLower = typed.toLowerCase();
|
||||
if (searched.indexOf(typedLower) === -1) {
|
||||
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);
|
||||
}
|
||||
})
|
||||
if (dropdown.active) buildSuggestions();
|
||||
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'
|
||||
)
|
||||
);
|
||||
});
|
||||
searched.push(typedLower);
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
|
||||
if (suggestions.length) {
|
||||
dropdown.items = suggestions;
|
||||
m.render($container[0], dropdown.render());
|
||||
|
||||
dropdown.show();
|
||||
const coordinates = app.composer.editor.getCaretCoordinates(absMentionStart);
|
||||
const width = dropdown.$().outerWidth();
|
||||
const height = dropdown.$().outerHeight();
|
||||
const parent = dropdown.$().offsetParent();
|
||||
let left = coordinates.left;
|
||||
let top = coordinates.top + 15;
|
||||
|
||||
// Keep the dropdown inside the editor.
|
||||
if (top + height > parent.height()) {
|
||||
top = coordinates.top - height - 15;
|
||||
}
|
||||
if (left + width > parent.width()) {
|
||||
left = parent.width() - width;
|
||||
}
|
||||
|
||||
// Prevent the dropdown from going off screen on mobile
|
||||
top = Math.max(-(parent.offset().top - $(document).scrollTop()), top);
|
||||
left = Math.max(-parent.offset().left, left);
|
||||
|
||||
dropdown.show(left, top);
|
||||
} else {
|
||||
dropdown.active = false;
|
||||
dropdown.hide();
|
||||
}
|
||||
};
|
||||
|
||||
dropdown.active = true;
|
||||
|
||||
buildSuggestions();
|
||||
|
||||
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) {
|
||||
throttledSearch(typed, searched, returnedUsers, returnedUserIds, dropdown, buildSuggestions);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
extend(TextEditor.prototype, 'toolbarItems', function(items) {
|
||||
items.add('mention', (
|
||||
extend(TextEditor.prototype, 'toolbarItems', function (items) {
|
||||
items.add(
|
||||
'mention',
|
||||
<TextEditorButton onclick={() => this.attrs.composer.editor.insertAtCursor(' @')} icon="fas fa-at">
|
||||
{app.translator.trans('flarum-mentions.forum.composer.mention_tooltip')}
|
||||
</TextEditorButton>
|
||||
));
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { extend } from 'flarum/extend';
|
||||
import Model from 'flarum/Model';
|
||||
import Post from 'flarum/models/Post';
|
||||
import CommentPost from 'flarum/components/CommentPost';
|
||||
import Link from 'flarum/components/Link';
|
||||
import PostPreview from 'flarum/components/PostPreview';
|
||||
import punctuateSeries from 'flarum/helpers/punctuateSeries';
|
||||
import username from 'flarum/helpers/username';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import { extend } from 'flarum/common/extend';
|
||||
import Model from 'flarum/common/Model';
|
||||
import Post from 'flarum/common/models/Post';
|
||||
import CommentPost from 'flarum/forum/components/CommentPost';
|
||||
import Link from 'flarum/common/components/Link';
|
||||
import PostPreview from 'flarum/forum/components/PostPreview';
|
||||
import punctuateSeries from 'flarum/common/helpers/punctuateSeries';
|
||||
import username from 'flarum/common/helpers/username';
|
||||
import icon from 'flarum/common/helpers/icon';
|
||||
|
||||
export default function addMentionedByList() {
|
||||
Post.prototype.mentionedBy = Model.hasMany('mentionedBy');
|
||||
|
@ -14,10 +14,12 @@ export default function addMentionedByList() {
|
|||
function hidePreview() {
|
||||
this.$('.Post-mentionedBy-preview')
|
||||
.removeClass('in')
|
||||
.one('transitionend', function() { $(this).hide(); });
|
||||
.one('transitionend', function () {
|
||||
$(this).hide();
|
||||
});
|
||||
}
|
||||
|
||||
extend(CommentPost.prototype, 'oncreate', function() {
|
||||
extend(CommentPost.prototype, 'oncreate', function () {
|
||||
let timeout;
|
||||
const post = this.attrs.post;
|
||||
const replies = post.mentionedBy();
|
||||
|
@ -35,22 +37,26 @@ export default function addMentionedByList() {
|
|||
// When the user hovers their mouse over the list of people who have
|
||||
// replied to the post, render a list of reply previews into a
|
||||
// popup.
|
||||
m.render($preview[0], replies.map(reply => (
|
||||
<li data-number={reply.number()}>
|
||||
{PostPreview.component({
|
||||
post: reply,
|
||||
onclick: hidePreview.bind(this)
|
||||
})}
|
||||
</li>
|
||||
)));
|
||||
m.render(
|
||||
$preview[0],
|
||||
replies.map((reply) => (
|
||||
<li data-number={reply.number()}>
|
||||
{PostPreview.component({
|
||||
post: reply,
|
||||
onclick: hidePreview.bind(this),
|
||||
})}
|
||||
</li>
|
||||
))
|
||||
);
|
||||
|
||||
$preview.show()
|
||||
$preview
|
||||
.show()
|
||||
.css('top', $this.offset().top - $parentPost.offset().top + $this.outerHeight(true))
|
||||
.css('left', $this.offsetParent().offset().left - $parentPost.offset().left)
|
||||
.css('max-width', $parentPost.width());
|
||||
|
||||
setTimeout(() => $preview.off('transitionend').addClass('in'));
|
||||
}
|
||||
};
|
||||
|
||||
$this.add($preview).hover(
|
||||
() => {
|
||||
|
@ -66,23 +72,28 @@ export default function addMentionedByList() {
|
|||
// Whenever the user hovers their mouse over a particular name in the
|
||||
// list of repliers, highlight the corresponding post in the preview
|
||||
// popup.
|
||||
this.$().find('.Post-mentionedBy-summary a').hover(function() {
|
||||
$preview.find('[data-number="' + $(this).data('number') + '"]').addClass('active');
|
||||
}, function() {
|
||||
$preview.find('[data-number]').removeClass('active');
|
||||
});
|
||||
this.$()
|
||||
.find('.Post-mentionedBy-summary a')
|
||||
.hover(
|
||||
function () {
|
||||
$preview.find('[data-number="' + $(this).data('number') + '"]').addClass('active');
|
||||
},
|
||||
function () {
|
||||
$preview.find('[data-number]').removeClass('active');
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
extend(CommentPost.prototype, 'footerItems', function(items) {
|
||||
extend(CommentPost.prototype, 'footerItems', function (items) {
|
||||
const post = this.attrs.post;
|
||||
const replies = post.mentionedBy();
|
||||
|
||||
if (replies && replies.length) {
|
||||
const users = [];
|
||||
const repliers = replies
|
||||
.sort(reply => reply.user() === app.session.user ? -1 : 0)
|
||||
.filter(reply => {
|
||||
.sort((reply) => (reply.user() === app.session.user ? -1 : 0))
|
||||
.filter((reply) => {
|
||||
const user = reply.user();
|
||||
if (users.indexOf(user) === -1) {
|
||||
users.push(user);
|
||||
|
@ -95,19 +106,15 @@ export default function addMentionedByList() {
|
|||
|
||||
// Create a list of unique users who have replied. So even if a user has
|
||||
// replied twice, they will only be in this array once.
|
||||
const names = repliers
|
||||
.slice(0, overLimit ? limit - 1 : limit)
|
||||
.map(reply => {
|
||||
const user = reply.user();
|
||||
const names = repliers.slice(0, overLimit ? limit - 1 : limit).map((reply) => {
|
||||
const user = reply.user();
|
||||
|
||||
return (
|
||||
<Link href={app.route.post(reply)}
|
||||
onclick={hidePreview.bind(this)}
|
||||
data-number={reply.number()}>
|
||||
{app.session.user === user ? app.translator.trans('flarum-mentions.forum.post.you_text') : username(user)}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Link href={app.route.post(reply)} onclick={hidePreview.bind(this)} data-number={reply.number()}>
|
||||
{app.session.user === user ? app.translator.trans('flarum-mentions.forum.post.you_text') : username(user)}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
// If there are more users that we've run out of room to display, add a "x
|
||||
// others" name to the end of the list. Clicking on it will display a modal
|
||||
|
@ -115,18 +122,17 @@ export default function addMentionedByList() {
|
|||
if (overLimit) {
|
||||
const count = repliers.length - names.length;
|
||||
|
||||
names.push(
|
||||
app.translator.trans('flarum-mentions.forum.post.others_text', {count})
|
||||
);
|
||||
names.push(app.translator.trans('flarum-mentions.forum.post.others_text', { count }));
|
||||
}
|
||||
|
||||
items.add('replies',
|
||||
items.add(
|
||||
'replies',
|
||||
<div className="Post-mentionedBy">
|
||||
<span className="Post-mentionedBy-summary">
|
||||
{icon('fas fa-reply')}
|
||||
{app.translator.trans('flarum-mentions.forum.post.mentioned_by' + (repliers[0].user() === app.session.user ? '_self' : '') + '_text', {
|
||||
count: names.length,
|
||||
users: punctuateSeries(names)
|
||||
users: punctuateSeries(names),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { extend } from 'flarum/extend';
|
||||
import CommentPost from 'flarum/components/CommentPost';
|
||||
import PostPreview from 'flarum/components/PostPreview';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
import { extend } from 'flarum/common/extend';
|
||||
import CommentPost from 'flarum/forum/components/CommentPost';
|
||||
import PostPreview from 'flarum/forum/components/PostPreview';
|
||||
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
|
||||
|
||||
export default function addPostMentionPreviews() {
|
||||
function addPreviews() {
|
||||
|
@ -19,7 +19,7 @@ export default function addPostMentionPreviews() {
|
|||
e.preventDefault();
|
||||
});
|
||||
|
||||
this.$('.PostMention:not(.PostMention--deleted)').each(function() {
|
||||
this.$('.PostMention:not(.PostMention--deleted)').each(function () {
|
||||
const $this = $(this);
|
||||
const id = $this.data('id');
|
||||
let timeout;
|
||||
|
@ -65,20 +65,25 @@ export default function addPostMentionPreviews() {
|
|||
offset -= previewHeight;
|
||||
}
|
||||
|
||||
$preview.show()
|
||||
$preview
|
||||
.show()
|
||||
.css('top', $this.offset().top - $parentPost.offset().top + offset)
|
||||
.css('left', $this.offsetParent().offset().left - $parentPost.offset().left)
|
||||
.css('max-width', $this.offsetParent().width());
|
||||
};
|
||||
|
||||
const showPost = post => {
|
||||
const showPost = (post) => {
|
||||
const discussion = post.discussion();
|
||||
|
||||
m.render($preview[0], [
|
||||
discussion !== parentPost.discussion()
|
||||
? <li><span className="PostMention-preview-discussion">{discussion.title()}</span></li>
|
||||
: '',
|
||||
<li>{PostPreview.component({post})}</li>
|
||||
discussion !== parentPost.discussion() ? (
|
||||
<li>
|
||||
<span className="PostMention-preview-discussion">{discussion.title()}</span>
|
||||
</li>
|
||||
) : (
|
||||
''
|
||||
),
|
||||
<li>{PostPreview.component({ post })}</li>,
|
||||
]);
|
||||
positionPreview();
|
||||
};
|
||||
|
@ -106,24 +111,26 @@ export default function addPostMentionPreviews() {
|
|||
// On a touch (mobile) device we cannot hover the link to reveal the preview.
|
||||
// Instead we cancel the navigation so that a click reveals the preview.
|
||||
// Users can then click on the preview to go to the post if desired.
|
||||
$this.on('touchend', e => {
|
||||
$this.on('touchend', (e) => {
|
||||
if (e.cancelable) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
$this.add($preview).hover(
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(showPreview, 250);
|
||||
},
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
getPostElement().removeClass('pulsate');
|
||||
timeout = setTimeout(hidePreview, 250);
|
||||
}
|
||||
)
|
||||
.on('touchend', e => {
|
||||
$this
|
||||
.add($preview)
|
||||
.hover(
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(showPreview, 250);
|
||||
},
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
getPostElement().removeClass('pulsate');
|
||||
timeout = setTimeout(hidePreview, 250);
|
||||
}
|
||||
)
|
||||
.on('touchend', (e) => {
|
||||
showPreview();
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
export default function cleanDisplayName(user) {
|
||||
return user.displayName().replace(/"#[a-z]{0,3}[0-9]+/, '_');
|
||||
};
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Whether to use the old mentions format.
|
||||
*
|
||||
* `'@username'` or `'@"Display name"'`
|
||||
*/
|
||||
export const shouldUseOldFormat = () => app.forum.attribute('allowUsernameMentionFormat') || false;
|
||||
|
||||
const getDeletedUserText = () => app.translator.trans('core.lib.username.deleted_text');
|
||||
|
||||
/**
|
||||
* Fetches a user's username or display name.
|
||||
*
|
||||
* Chooses based on the format option set in the admin settings page.
|
||||
*
|
||||
* @param user An instance of the User model to fetch the username for
|
||||
* @param useDisplayName If `true`, uses `user.displayName()`, otherwise, uses `user.username()`
|
||||
*/
|
||||
export default function getCleanDisplayName(user, useDisplayName = true) {
|
||||
if (!user) return getDeletedUserText().replace(/"#[a-z]{0,3}[0-9]+/, '_');
|
||||
|
||||
const text = (useDisplayName ? user.displayName() : user.username()) || getDeletedUserText();
|
||||
|
||||
return text.replace(/"#[a-z]{0,3}[0-9]+/, '_');
|
||||
}
|
36
extensions/mentions/js/src/forum/utils/getMentionText.js
Normal file
36
extensions/mentions/js/src/forum/utils/getMentionText.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
import getCleanDisplayName, { ShouldUseOldFormat } from './getCleanDisplayName';
|
||||
|
||||
/**
|
||||
* Fetches the mention text for a specified user (and optionally a post ID for replies).
|
||||
*
|
||||
* 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'
|
||||
*/
|
||||
export default function getMentionText(user, postId) {
|
||||
if (postId === undefined) {
|
||||
if (ShouldUseOldFormat()) {
|
||||
// Plain @username
|
||||
const cleanText = getCleanDisplayName(user, false);
|
||||
return `@${cleanText}`;
|
||||
}
|
||||
// @"Display name"#UserID
|
||||
const cleanText = getCleanDisplayName(user);
|
||||
return `@"${cleanText}"${user.id()}`;
|
||||
} else {
|
||||
// @"Display name"#pPostID
|
||||
const cleanText = getCleanDisplayName(user);
|
||||
return `@"${cleanText}"#p${postId}`;
|
||||
}
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
import DiscussionControls from 'flarum/utils/DiscussionControls';
|
||||
import EditPostComposer from 'flarum/components/EditPostComposer';
|
||||
import cleanDisplayName from './cleanDisplayName';
|
||||
import DiscussionControls from 'flarum/forum/utils/DiscussionControls';
|
||||
import EditPostComposer from 'flarum/forum/components/EditPostComposer';
|
||||
import getMentionText from './getMentionText';
|
||||
|
||||
function insertMention(post, composer, quote) {
|
||||
const user = post.user();
|
||||
const mention = `@"${(user && cleanDisplayName(user)) || app.translator.trans('core.lib.username.deleted_text')}"#p${post.id()} `;
|
||||
const mention = getMentionText(user, post.id());
|
||||
|
||||
// If the composer is empty, then assume we're starting a new reply.
|
||||
// In which case we don't want the user to have to confirm if they
|
||||
|
@ -19,9 +19,7 @@ function insertMention(post, composer, quote) {
|
|||
|
||||
composer.editor.insertAtCursor(
|
||||
Array(precedingNewlines).join('\n') + // Insert up to two newlines, depending on preceding whitespace
|
||||
(quote
|
||||
? '> ' + mention + quote.trim().replace(/\n/g, '\n> ') + '\n\n'
|
||||
: mention),
|
||||
(quote ? '> ' + mention + quote.trim().replace(/\n/g, '\n> ') + '\n\n' : mention),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
@ -35,7 +33,6 @@ export default function reply(post, quote) {
|
|||
// The default "Reply" action behavior will only open a new composer if
|
||||
// necessary, but it will always be a ReplyComposer, hence the exceptional
|
||||
// case above.
|
||||
DiscussionControls.replyAction.call(post.discussion())
|
||||
.then(composer => insertMention(post, composer, quote));
|
||||
DiscussionControls.replyAction.call(post.discussion()).then((composer) => insertMention(post, composer, quote));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,28 +1,33 @@
|
|||
/**
|
||||
* Finds the selected text in the provided composer body.
|
||||
*/
|
||||
export default function selectedText(body) {
|
||||
const selection = window.getSelection();
|
||||
if (selection.rangeCount) {
|
||||
|
||||
if (selection?.rangeCount) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const parent = range.commonAncestorContainer;
|
||||
|
||||
if (body[0] === parent || $.contains(body[0], parent)) {
|
||||
const clone = $("<div>").append(range.cloneContents());
|
||||
const clone = $('<div>').append(range.cloneContents());
|
||||
|
||||
// Replace emoji images with their shortcode (found in alt attribute)
|
||||
clone.find('img.emoji').replaceWith(function() {
|
||||
clone.find('img.emoji').replaceWith(function () {
|
||||
return this.alt;
|
||||
});
|
||||
|
||||
// Replace all other images with a Markdown image
|
||||
clone.find('img').replaceWith(function() {
|
||||
return '![](' + this.src + ')';
|
||||
clone.find('img').replaceWith(function () {
|
||||
return `![](${this.src})`;
|
||||
});
|
||||
|
||||
// Replace all links with a Markdown link
|
||||
clone.find('a').replaceWith(function() {
|
||||
return '[' + this.innerText + '](' + this.href + ')';
|
||||
clone.find('a').replaceWith(function () {
|
||||
return `[${this.innerText}](${this.href})`;
|
||||
});
|
||||
|
||||
return clone.text();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
return '';
|
||||
}
|
||||
|
|
16
extensions/mentions/js/tsconfig.json
Normal file
16
extensions/mentions/js/tsconfig.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
// Use Flarum's tsconfig as a starting point
|
||||
"extends": "flarum-tsconfig",
|
||||
// This will match all .ts, .tsx, .d.ts, .js, .jsx files in your `src` folder
|
||||
// and also tells your Typescript server to read core's global typings for
|
||||
// access to `dayjs` and `$` in the global namespace.
|
||||
"include": ["src/**/*", "../vendor/flarum/core/js/dist-typings/@types/**/*"],
|
||||
"compilerOptions": {
|
||||
// This will output typings to `dist-typings`
|
||||
"declarationDir": "./dist-typings",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"flarum/*": ["../vendor/flarum/core/js/dist-typings/*"]
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user