mirror of
https://github.com/flarum/core.git
synced 2025-08-16 21:34:08 +02:00
Webpack (#33)
See https://github.com/flarum/core/pull/1367 * Replace gulp with webpack and npm scripts for JS compilation * Set up Travis CI to commit compiled JS * Restructure `js` directory; only one instance of npm, forum/admin are "submodules" * Restructure `less` directory
This commit is contained in:
192
extensions/mentions/js/src/forum/addComposerAutocomplete.js
Normal file
192
extensions/mentions/js/src/forum/addComposerAutocomplete.js
Normal file
@@ -0,0 +1,192 @@
|
||||
import getCaretCoordinates from 'textarea-caret';
|
||||
|
||||
import { extend } from 'flarum/extend';
|
||||
import ComposerBody from 'flarum/components/ComposerBody';
|
||||
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 AutocompleteDropdown from './components/AutocompleteDropdown';
|
||||
|
||||
export default function addComposerAutocomplete() {
|
||||
extend(ComposerBody.prototype, 'config', function(original, isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
const composer = this;
|
||||
const $container = $('<div class="ComposerBody-mentionsDropdownContainer"></div>');
|
||||
const dropdown = new AutocompleteDropdown({items: []});
|
||||
const $textarea = this.$('textarea').wrap('<div class="ComposerBody-mentionsWrapper"></div>');
|
||||
const searched = [];
|
||||
let mentionStart;
|
||||
let typed;
|
||||
let searchTimeout;
|
||||
|
||||
const applySuggestion = function(replacement) {
|
||||
const insert = replacement + ' ';
|
||||
|
||||
const content = composer.content();
|
||||
composer.editor.setValue(content.substring(0, mentionStart - 1) + insert + content.substr($textarea[0].selectionStart));
|
||||
|
||||
const index = mentionStart - 1 + insert.length;
|
||||
composer.editor.setSelectionRange(index, index);
|
||||
|
||||
dropdown.hide();
|
||||
};
|
||||
|
||||
this.navigator = new KeyboardNavigatable();
|
||||
this.navigator
|
||||
.when(() => dropdown.active)
|
||||
.onUp(() => dropdown.navigate(-1))
|
||||
.onDown(() => dropdown.navigate(1))
|
||||
.onSelect(dropdown.complete.bind(dropdown))
|
||||
.onCancel(dropdown.hide.bind(dropdown))
|
||||
.bindTo($textarea);
|
||||
|
||||
$textarea
|
||||
.after($container)
|
||||
.on('click keyup', function(e) {
|
||||
// Up, down, enter, tab, escape, left, right.
|
||||
if ([9, 13, 27, 40, 38, 37, 39].indexOf(e.which) !== -1) return;
|
||||
|
||||
const cursor = this.selectionStart;
|
||||
|
||||
if (this.selectionEnd - cursor > 0) return;
|
||||
|
||||
// Search backwards from the cursor for an '@' symbol. If we find one,
|
||||
// we will want to show the autocomplete dropdown!
|
||||
const value = this.value;
|
||||
mentionStart = 0;
|
||||
for (let i = cursor - 1; i >= cursor - 30; i--) {
|
||||
const character = value.substr(i, 1);
|
||||
if (character === '@') {
|
||||
mentionStart = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dropdown.hide();
|
||||
dropdown.active = false;
|
||||
|
||||
if (mentionStart) {
|
||||
typed = value.substring(mentionStart, cursor).toLowerCase();
|
||||
|
||||
const makeSuggestion = function(user, replacement, content, className = '') {
|
||||
const username = usernameHelper(user);
|
||||
if (typed) {
|
||||
username.children[0] = highlight(username.children[0], typed);
|
||||
}
|
||||
|
||||
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 userMatches = function(user) {
|
||||
const names = [
|
||||
user.username(),
|
||||
user.displayName()
|
||||
];
|
||||
|
||||
return names.some(value => value.toLowerCase().substr(0, typed.length) === typed);
|
||||
};
|
||||
|
||||
const buildSuggestions = () => {
|
||||
const suggestions = [];
|
||||
|
||||
// If the user has started to type a username, then suggest users
|
||||
// matching that username.
|
||||
if (typed) {
|
||||
app.store.all('users').forEach(user => {
|
||||
if (!userMatches(user)) return;
|
||||
|
||||
suggestions.push(
|
||||
makeSuggestion(user, '@' + user.username(), '', 'MentionsDropdown-user')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
const composerPost = composer.props.post;
|
||||
const discussion = (composerPost && composerPost.discussion()) || composer.props.discussion;
|
||||
if (discussion) {
|
||||
discussion.posts()
|
||||
.filter(post => post && post.contentType() === 'comment' && (!composerPost || post.number() < composerPost.number()))
|
||||
.sort((a, b) => b.time() - a.time())
|
||||
.filter(post => {
|
||||
const user = post.user();
|
||||
return user && userMatches(user);
|
||||
})
|
||||
.splice(0, 5)
|
||||
.forEach(post => {
|
||||
const user = post.user();
|
||||
suggestions.push(
|
||||
makeSuggestion(user, '@' + user.username() + '#' + post.id(), [
|
||||
app.translator.trans('flarum-mentions.forum.composer.reply_to_post_text', {number: post.number()}), ' — ',
|
||||
truncate(post.contentPlain(), 200)
|
||||
], 'MentionsDropdown-post')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (suggestions.length) {
|
||||
dropdown.props.items = suggestions;
|
||||
m.render($container[0], dropdown.render());
|
||||
|
||||
dropdown.show();
|
||||
const coordinates = getCaretCoordinates(this, mentionStart);
|
||||
const width = dropdown.$().outerWidth();
|
||||
const height = dropdown.$().outerHeight();
|
||||
const parent = dropdown.$().offsetParent();
|
||||
let left = coordinates.left;
|
||||
let top = coordinates.top + 15;
|
||||
if (top + height > parent.height()) {
|
||||
top = coordinates.top - height - 15;
|
||||
}
|
||||
if (left + width > parent.width()) {
|
||||
left = parent.width() - width;
|
||||
}
|
||||
dropdown.show(left, top);
|
||||
} else {
|
||||
dropdown.active = false;
|
||||
dropdown.hide();
|
||||
}
|
||||
};
|
||||
|
||||
dropdown.active = true;
|
||||
|
||||
buildSuggestions();
|
||||
|
||||
dropdown.setIndex(0);
|
||||
dropdown.$().scrollTop(0);
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
if (typed) {
|
||||
searchTimeout = setTimeout(function() {
|
||||
const typedLower = typed.toLowerCase();
|
||||
if (searched.indexOf(typedLower) === -1) {
|
||||
app.store.find('users', {filter: {q: typed}, page: {limit: 5}}).then(() => {
|
||||
if (dropdown.active) buildSuggestions();
|
||||
});
|
||||
searched.push(typedLower);
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
128
extensions/mentions/js/src/forum/addMentionedByList.js
Normal file
128
extensions/mentions/js/src/forum/addMentionedByList.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import { extend } from 'flarum/extend';
|
||||
import Model from 'flarum/Model';
|
||||
import Post from 'flarum/models/Post';
|
||||
import CommentPost from 'flarum/components/CommentPost';
|
||||
import PostPreview from 'flarum/components/PostPreview';
|
||||
import punctuateSeries from 'flarum/helpers/punctuateSeries';
|
||||
import username from 'flarum/helpers/username';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
export default function addMentionedByList() {
|
||||
Post.prototype.mentionedBy = Model.hasMany('mentionedBy');
|
||||
|
||||
extend(CommentPost.prototype, 'footerItems', function(items) {
|
||||
const post = this.props.post;
|
||||
const replies = post.mentionedBy();
|
||||
|
||||
if (replies && replies.length) {
|
||||
// If there is only one reply, and it's adjacent to this post, we don't
|
||||
// really need to show the list.
|
||||
if (replies.length === 1 && replies[0].number() === post.number() + 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hidePreview = () => {
|
||||
this.$('.Post-mentionedBy-preview')
|
||||
.removeClass('in')
|
||||
.one('transitionend', function() { $(this).hide(); });
|
||||
};
|
||||
|
||||
const config = function(element, isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
const $this = $(element);
|
||||
let timeout;
|
||||
|
||||
const $preview = $('<ul class="Dropdown-menu Post-mentionedBy-preview fade"/>');
|
||||
$this.append($preview);
|
||||
|
||||
$this.children().hover(function() {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(function() {
|
||||
if (!$preview.hasClass('in') && $preview.is(':visible')) return;
|
||||
|
||||
// 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
|
||||
})}
|
||||
</li>
|
||||
)));
|
||||
$preview.show();
|
||||
setTimeout(() => $preview.off('transitionend').addClass('in'));
|
||||
}, 500);
|
||||
}, function() {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(hidePreview, 250);
|
||||
});
|
||||
|
||||
// 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');
|
||||
});
|
||||
};
|
||||
|
||||
const users = [];
|
||||
const repliers = replies
|
||||
.sort(reply => reply.user() === app.session.user ? -1 : 0)
|
||||
.filter(reply => {
|
||||
const user = reply.user();
|
||||
if (users.indexOf(user) === -1) {
|
||||
users.push(user);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
const limit = 4;
|
||||
const overLimit = repliers.length > limit;
|
||||
|
||||
// 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();
|
||||
|
||||
return (
|
||||
<a href={app.route.post(reply)}
|
||||
config={m.route}
|
||||
onclick={hidePreview}
|
||||
data-number={reply.number()}>
|
||||
{app.session.user === user ? app.translator.trans('flarum-mentions.forum.post.you_text') : username(user)}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
// 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
|
||||
// with a full list of names.
|
||||
if (overLimit) {
|
||||
const count = repliers.length - names.length;
|
||||
|
||||
names.push(
|
||||
app.translator.transChoice('flarum-mentions.forum.post.others_text', count, {count})
|
||||
);
|
||||
}
|
||||
|
||||
items.add('replies',
|
||||
<div className="Post-mentionedBy" config={config}>
|
||||
<span className="Post-mentionedBy-summary">
|
||||
{icon('fas fa-reply')}
|
||||
{app.translator.transChoice('flarum-mentions.forum.post.mentioned_by' + (repliers[0].user() === app.session.user ? '_self' : '') + '_text', names.length, {
|
||||
count: names.length,
|
||||
users: punctuateSeries(names)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
126
extensions/mentions/js/src/forum/addPostMentionPreviews.js
Normal file
126
extensions/mentions/js/src/forum/addPostMentionPreviews.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import { extend } from 'flarum/extend';
|
||||
import CommentPost from 'flarum/components/CommentPost';
|
||||
import PostPreview from 'flarum/components/PostPreview';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
|
||||
export default function addPostMentionPreviews() {
|
||||
extend(CommentPost.prototype, 'config', function() {
|
||||
const contentHtml = this.props.post.contentHtml();
|
||||
|
||||
if (contentHtml === this.oldPostContentHtml || this.isEditing()) return;
|
||||
|
||||
this.oldPostContentHtml = contentHtml;
|
||||
|
||||
const parentPost = this.props.post;
|
||||
const $parentPost = this.$();
|
||||
|
||||
this.$('.UserMention, .PostMention').each(function() {
|
||||
m.route.call(this, this, false, {}, {attrs: {href: this.getAttribute('href')}});
|
||||
});
|
||||
|
||||
this.$('.PostMention').each(function() {
|
||||
const $this = $(this);
|
||||
const id = $this.data('id');
|
||||
let timeout;
|
||||
|
||||
// Wrap the mention link in a wrapper element so that we can insert a
|
||||
// preview popup as its sibling and relatively position it.
|
||||
const $preview = $('<ul class="Dropdown-menu PostMention-preview fade"/>');
|
||||
$parentPost.append($preview);
|
||||
|
||||
const getPostElement = () => {
|
||||
return $(`.PostStream-item[data-id="${id}"]`);
|
||||
};
|
||||
|
||||
const showPreview = () => {
|
||||
// When the user hovers their mouse over the mention, look for the
|
||||
// post that it's referring to in the stream, and determine if it's
|
||||
// in the viewport. If it is, we will "pulsate" it.
|
||||
const $post = getPostElement();
|
||||
let visible = false;
|
||||
if ($post.length) {
|
||||
const top = $post.offset().top;
|
||||
const scrollTop = window.pageYOffset;
|
||||
if (top > scrollTop && top + $post.height() < scrollTop + $(window).height()) {
|
||||
$post.addClass('pulsate');
|
||||
visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, we will show a popup preview of the post. If the post
|
||||
// hasn't yet been loaded, we will need to do that.
|
||||
if (!visible) {
|
||||
// Position the preview so that it appears above the mention.
|
||||
// (The offsetParent should be .Post-body.)
|
||||
const positionPreview = () => {
|
||||
const previewHeight = $preview.outerHeight(true);
|
||||
let offset = 0;
|
||||
|
||||
// If the preview goes off the top of the viewport, reposition it to
|
||||
// be below the mention.
|
||||
if ($this.offset().top - previewHeight < $(window).scrollTop() + $('#header').outerHeight()) {
|
||||
offset += $this.outerHeight(true);
|
||||
} else {
|
||||
offset -= previewHeight;
|
||||
}
|
||||
|
||||
$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 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>
|
||||
]);
|
||||
positionPreview();
|
||||
};
|
||||
|
||||
const post = app.store.getById('posts', id);
|
||||
if (post && post.discussion()) {
|
||||
showPost(post);
|
||||
} else {
|
||||
m.render($preview[0], LoadingIndicator.component());
|
||||
app.store.find('posts', id).then(showPost);
|
||||
positionPreview();
|
||||
}
|
||||
|
||||
setTimeout(() => $preview.off('transitionend').addClass('in'));
|
||||
}
|
||||
};
|
||||
|
||||
const hidePreview = () => {
|
||||
getPostElement().removeClass('pulsate');
|
||||
if ($preview.hasClass('in')) {
|
||||
$preview.removeClass('in').one('transitionend', () => $preview.hide());
|
||||
}
|
||||
};
|
||||
|
||||
$this.on('touchstart', e => e.preventDefault());
|
||||
|
||||
$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();
|
||||
});
|
||||
|
||||
$(document).on('touchend', hidePreview);
|
||||
});
|
||||
});
|
||||
}
|
45
extensions/mentions/js/src/forum/addPostQuoteButton.js
Normal file
45
extensions/mentions/js/src/forum/addPostQuoteButton.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { extend } from 'flarum/extend';
|
||||
import CommentPost from 'flarum/components/CommentPost';
|
||||
|
||||
import PostQuoteButton from './components/PostQuoteButton';
|
||||
import selectedText from './utils/selectedText';
|
||||
|
||||
export default function addPostQuoteButton() {
|
||||
extend(CommentPost.prototype, 'config', function(original, isInitialized) {
|
||||
const post = this.props.post;
|
||||
|
||||
if (isInitialized || post.isHidden() || (app.session.user && !post.discussion().canReply())) return;
|
||||
|
||||
const $postBody = this.$('.Post-body');
|
||||
|
||||
// Wrap the quote button in a wrapper element so that we can render
|
||||
// button into it.
|
||||
const $container = $('<div class="Post-quoteButtonContainer"></div>');
|
||||
|
||||
const handler = function(e) {
|
||||
setTimeout(() => {
|
||||
const content = selectedText($postBody);
|
||||
if (content) {
|
||||
const button = new PostQuoteButton({post, content});
|
||||
m.render($container[0], button.render());
|
||||
|
||||
const rects = window.getSelection().getRangeAt(0).getClientRects();
|
||||
const firstRect = rects[0];
|
||||
|
||||
if (e.clientY < firstRect.bottom && e.clientX - firstRect.right < firstRect.left - e.clientX) {
|
||||
button.showStart(firstRect.left, firstRect.top);
|
||||
} else {
|
||||
const lastRect = rects[rects.length - 1];
|
||||
button.showEnd(lastRect.right, lastRect.bottom);
|
||||
}
|
||||
}
|
||||
}, 1);
|
||||
};
|
||||
|
||||
this.$().after($container).on('mouseup', handler);
|
||||
|
||||
if ('ontouchstart' in window) {
|
||||
document.addEventListener('selectionchange', handler, false);
|
||||
}
|
||||
});
|
||||
}
|
22
extensions/mentions/js/src/forum/addPostReplyAction.js
Normal file
22
extensions/mentions/js/src/forum/addPostReplyAction.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { extend } from 'flarum/extend';
|
||||
import Button from 'flarum/components/Button';
|
||||
import CommentPost from 'flarum/components/CommentPost';
|
||||
|
||||
import reply from './utils/reply';
|
||||
|
||||
export default function () {
|
||||
extend(CommentPost.prototype, 'actionItems', function (items) {
|
||||
|
||||
const post = this.props.post;
|
||||
|
||||
if (post.isHidden() || (app.session.user && !post.discussion().canReply())) return;
|
||||
|
||||
items.add('reply',
|
||||
Button.component({
|
||||
className: 'Button Button--link',
|
||||
children: app.translator.trans('flarum-mentions.forum.post.reply_link'),
|
||||
onclick: () => reply(post)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
@@ -0,0 +1,78 @@
|
||||
import Component from 'flarum/Component';
|
||||
|
||||
export default class AutocompleteDropdown extends Component {
|
||||
init() {
|
||||
this.active = false;
|
||||
this.index = 0;
|
||||
this.keyWasJustPressed = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<ul className="Dropdown-menu MentionsDropdown">
|
||||
{this.props.items.map(item => <li>{item}</li>)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
show(left, top) {
|
||||
this.$().show().css({
|
||||
left: left + 'px',
|
||||
top: top + 'px'
|
||||
});
|
||||
this.active = true;
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.$().hide();
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
navigate(delta) {
|
||||
this.keyWasJustPressed = true;
|
||||
this.setIndex(this.index + delta, true);
|
||||
clearTimeout(this.keyWasJustPressedTimeout);
|
||||
this.keyWasJustPressedTimeout = setTimeout(() => this.keyWasJustPressed = false, 500);
|
||||
}
|
||||
|
||||
complete() {
|
||||
this.$('li').eq(this.index).find('button').click();
|
||||
}
|
||||
|
||||
setIndex(index, scrollToItem) {
|
||||
if (this.keyWasJustPressed && !scrollToItem) return;
|
||||
|
||||
const $dropdown = this.$();
|
||||
const $items = $dropdown.find('li');
|
||||
let rangedIndex = index;
|
||||
|
||||
if (rangedIndex < 0) {
|
||||
rangedIndex = $items.length - 1;
|
||||
} else if (rangedIndex >= $items.length) {
|
||||
rangedIndex = 0;
|
||||
}
|
||||
|
||||
this.index = rangedIndex;
|
||||
|
||||
const $item = $items.removeClass('active').eq(rangedIndex).addClass('active');
|
||||
|
||||
if (scrollToItem) {
|
||||
const dropdownScroll = $dropdown.scrollTop();
|
||||
const dropdownTop = $dropdown.offset().top;
|
||||
const dropdownBottom = dropdownTop + $dropdown.outerHeight();
|
||||
const itemTop = $item.offset().top;
|
||||
const itemBottom = itemTop + $item.outerHeight();
|
||||
|
||||
let scrollTop;
|
||||
if (itemTop < dropdownTop) {
|
||||
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
|
||||
} else if (itemBottom > dropdownBottom) {
|
||||
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
|
||||
}
|
||||
|
||||
if (typeof scrollTop !== 'undefined') {
|
||||
$dropdown.stop(true).animate({scrollTop}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,24 @@
|
||||
import PostsUserPage from 'flarum/components/PostsUserPage';
|
||||
|
||||
/**
|
||||
* The `MentionsUserPage` component shows post which user Mentioned at
|
||||
*/
|
||||
export default class MentionsUserPage extends PostsUserPage {
|
||||
/**
|
||||
* Load a new page of the user's activity feed.
|
||||
*
|
||||
* @param {Integer} [offset] The position to start getting results from.
|
||||
* @return {Promise}
|
||||
* @protected
|
||||
*/
|
||||
loadResults(offset) {
|
||||
return app.store.find('posts', {
|
||||
filter: {
|
||||
type: 'comment',
|
||||
mentioned: this.user.id()
|
||||
},
|
||||
page: {offset, limit: this.loadLimit},
|
||||
sort: '-time'
|
||||
});
|
||||
}
|
||||
}
|
@@ -0,0 +1,36 @@
|
||||
import Notification from 'flarum/components/Notification';
|
||||
import username from 'flarum/helpers/username';
|
||||
import punctuateSeries from 'flarum/helpers/punctuateSeries';
|
||||
|
||||
export default class PostMentionedNotification extends Notification {
|
||||
icon() {
|
||||
return 'fas fa-reply';
|
||||
}
|
||||
|
||||
href() {
|
||||
const notification = this.props.notification;
|
||||
const post = notification.subject();
|
||||
const auc = notification.additionalUnreadCount();
|
||||
const content = notification.content();
|
||||
|
||||
return app.route.discussion(post.discussion(), auc ? post.number() : (content && content.replyNumber));
|
||||
}
|
||||
|
||||
content() {
|
||||
const notification = this.props.notification;
|
||||
const auc = notification.additionalUnreadCount();
|
||||
const user = notification.sender();
|
||||
|
||||
return app.translator.transChoice('flarum-mentions.forum.notifications.post_mentioned_text', auc + 1, {
|
||||
user,
|
||||
username: auc ? punctuateSeries([
|
||||
username(user),
|
||||
app.translator.transChoice('flarum-mentions.forum.notifications.others_text', auc, {count: auc})
|
||||
]) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
excerpt() {
|
||||
return this.props.notification.subject().contentPlain();
|
||||
}
|
||||
}
|
@@ -0,0 +1,53 @@
|
||||
import Button from 'flarum/components/Button';
|
||||
import extract from 'flarum/utils/extract';
|
||||
|
||||
import reply from '../utils/reply';
|
||||
|
||||
export default class PostQuoteButton extends Button {
|
||||
view() {
|
||||
const post = extract(this.props, 'post');
|
||||
const content = extract(this.props, 'content');
|
||||
|
||||
this.props.className = 'Button PostQuoteButton';
|
||||
this.props.icon = 'fas fa-quote-left';
|
||||
this.props.children = app.translator.trans('flarum-mentions.forum.post.quote_button');
|
||||
this.props.onclick = () => {
|
||||
this.hide();
|
||||
reply(post, content);
|
||||
};
|
||||
this.props.onmousedown = (e) => e.stopPropagation();
|
||||
|
||||
return super.view();
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
$(document).on('mousedown', this.hide.bind(this));
|
||||
}
|
||||
|
||||
show(left, top) {
|
||||
const $this = this.$().show();
|
||||
const parentOffset = $this.offsetParent().offset();
|
||||
|
||||
$this
|
||||
.css('left', left - parentOffset.left)
|
||||
.css('top', top - parentOffset.top);
|
||||
}
|
||||
|
||||
showStart(left, top) {
|
||||
const $this = this.$();
|
||||
|
||||
this.show(left, $(window).scrollTop() + top - $this.outerHeight() - 5);
|
||||
}
|
||||
|
||||
showEnd(right, bottom) {
|
||||
const $this = this.$();
|
||||
|
||||
this.show(right - $this.outerWidth(), $(window).scrollTop() + bottom + 5);
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.$().hide();
|
||||
}
|
||||
}
|
@@ -0,0 +1,23 @@
|
||||
import Notification from 'flarum/components/Notification';
|
||||
|
||||
export default class UserMentionedNotification extends Notification {
|
||||
icon() {
|
||||
return 'fas fa-at';
|
||||
}
|
||||
|
||||
href() {
|
||||
const post = this.props.notification.subject();
|
||||
|
||||
return app.route.discussion(post.discussion(), post.number());
|
||||
}
|
||||
|
||||
content() {
|
||||
const user = this.props.notification.sender();
|
||||
|
||||
return app.translator.trans('flarum-mentions.forum.notifications.user_mentioned_text', {user});
|
||||
}
|
||||
|
||||
excerpt() {
|
||||
return this.props.notification.subject().contentPlain();
|
||||
}
|
||||
}
|
72
extensions/mentions/js/src/forum/index.js
Normal file
72
extensions/mentions/js/src/forum/index.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { extend } from 'flarum/extend';
|
||||
import app from 'flarum/app';
|
||||
import NotificationGrid from 'flarum/components/NotificationGrid';
|
||||
import { getPlainContent } from 'flarum/utils/string';
|
||||
|
||||
import addPostMentionPreviews from './addPostMentionPreviews';
|
||||
import addMentionedByList from './addMentionedByList';
|
||||
import addPostReplyAction from './addPostReplyAction';
|
||||
import addPostQuoteButton from './addPostQuoteButton';
|
||||
import addComposerAutocomplete from './addComposerAutocomplete';
|
||||
import PostMentionedNotification from './components/PostMentionedNotification';
|
||||
import UserMentionedNotification from './components/UserMentionedNotification';
|
||||
import UserPage from 'flarum/components/UserPage'
|
||||
import LinkButton from 'flarum/components/LinkButton';
|
||||
import MentionsUserPage from './components/MentionsUserPage';
|
||||
|
||||
app.initializers.add('flarum-mentions', function() {
|
||||
// For every mention of a post inside a post's content, set up a hover handler
|
||||
// that shows a preview of the mentioned post.
|
||||
addPostMentionPreviews();
|
||||
|
||||
// In the footer of each post, show information about who has replied (i.e.
|
||||
// who the post has been mentioned by).
|
||||
addMentionedByList();
|
||||
|
||||
// Add a 'reply' control to the footer of each post. When clicked, it will
|
||||
// open up the composer and add a post mention to its contents.
|
||||
addPostReplyAction();
|
||||
|
||||
// Show a Quote button when Post text is selected
|
||||
addPostQuoteButton();
|
||||
|
||||
// After typing '@' in the composer, show a dropdown suggesting a bunch of
|
||||
// posts or users that the user could mention.
|
||||
addComposerAutocomplete();
|
||||
|
||||
app.notificationComponents.postMentioned = PostMentionedNotification;
|
||||
app.notificationComponents.userMentioned = UserMentionedNotification;
|
||||
|
||||
// Add notification preferences.
|
||||
extend(NotificationGrid.prototype, 'notificationTypes', function(items) {
|
||||
items.add('postMentioned', {
|
||||
name: 'postMentioned',
|
||||
icon: 'fas fa-reply',
|
||||
label: app.translator.trans('flarum-mentions.forum.settings.notify_post_mentioned_label')
|
||||
});
|
||||
|
||||
items.add('userMentioned', {
|
||||
name: 'userMentioned',
|
||||
icon: 'fas fa-at',
|
||||
label: app.translator.trans('flarum-mentions.forum.settings.notify_user_mentioned_label')
|
||||
});
|
||||
});
|
||||
|
||||
// Add mentions tab in user profile
|
||||
app.routes['user.mentions'] = {path: '/u/:username/mentions', component: MentionsUserPage.component()};
|
||||
extend(UserPage.prototype, 'navItems', function(items) {
|
||||
const user = this.user;
|
||||
items.add('mentions',
|
||||
LinkButton.component({
|
||||
href: app.route('user.mentions', {username: user.username()}),
|
||||
name: 'mentions',
|
||||
children: [app.translator.trans('flarum-mentions.forum.user.mentions_link')],
|
||||
icon: 'fas fa-at'
|
||||
}),
|
||||
80
|
||||
);
|
||||
});
|
||||
|
||||
// Remove post mentions when rendering post previews.
|
||||
getPlainContent.removeSelectors.push('a.PostMention');
|
||||
});
|
34
extensions/mentions/js/src/forum/utils/reply.js
Normal file
34
extensions/mentions/js/src/forum/utils/reply.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import DiscussionControls from 'flarum/utils/DiscussionControls';
|
||||
|
||||
function insertMention(post, component, quote) {
|
||||
const user = post.user();
|
||||
const mention = '@' + (user ? user.username() : post.number()) + '#' + 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
|
||||
// close the composer straight away.
|
||||
if (!component.content()) {
|
||||
component.props.originalContent = mention;
|
||||
}
|
||||
|
||||
const cursorPosition = component.editor.getSelectionRange()[0];
|
||||
const preceding = component.editor.value().slice(0, cursorPosition);
|
||||
const precedingNewlines = preceding.length == 0 ? 0 : 3 - preceding.match(/(\n{0,2})$/)[0].length;
|
||||
|
||||
component.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)
|
||||
);
|
||||
}
|
||||
|
||||
export default function reply(post, quote) {
|
||||
const component = app.composer.component;
|
||||
if (component && component.props.post && component.props.post.discussion() === post.discussion()) {
|
||||
insertMention(post, component, quote);
|
||||
} else {
|
||||
DiscussionControls.replyAction.call(post.discussion())
|
||||
.then(newComponent => insertMention(post, newComponent, quote));
|
||||
}
|
||||
}
|
28
extensions/mentions/js/src/forum/utils/selectedText.js
Normal file
28
extensions/mentions/js/src/forum/utils/selectedText.js
Normal file
@@ -0,0 +1,28 @@
|
||||
export default function selectedText(body) {
|
||||
const selection = window.getSelection();
|
||||
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());
|
||||
|
||||
// Replace emoji images with their shortcode (found in alt attribute)
|
||||
clone.find('img.emoji').replaceWith(function() {
|
||||
return this.alt;
|
||||
});
|
||||
|
||||
// Replace all other images with a Markdown image
|
||||
clone.find('img').replaceWith(function() {
|
||||
return '';
|
||||
});
|
||||
|
||||
// Replace all links with a Markdown link
|
||||
clone.find('a').replaceWith(function() {
|
||||
return '[' + this.innerText + '](' + this.href + ')';
|
||||
});
|
||||
|
||||
return clone.text();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
25
extensions/mentions/js/src/forum/utils/textFormatter.js
Normal file
25
extensions/mentions/js/src/forum/utils/textFormatter.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import username from 'flarum/helpers/username';
|
||||
import extractText from 'flarum/utils/extractText';
|
||||
|
||||
export function filterUserMentions(tag) {
|
||||
const user = app.store.getBy('users', 'username', tag.getAttribute('username'));
|
||||
|
||||
if (user) {
|
||||
tag.setAttribute('id', user.id());
|
||||
tag.setAttribute('displayname', extractText(username(user)));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterPostMentions(tag) {
|
||||
const post = app.store.getById('posts', tag.getAttribute('id'));
|
||||
|
||||
if (post) {
|
||||
tag.setAttribute('discussionid', post.discussion().id());
|
||||
tag.setAttribute('number', post.number());
|
||||
tag.setAttribute('displayname', extractText(username(post.user())));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user