mirror of
https://github.com/flarum/core.git
synced 2025-07-30 21:20:24 +02:00
Update for new API + TextFormatter
This commit is contained in:
44
extensions/mentions/js/bootstrap.js
vendored
44
extensions/mentions/js/bootstrap.js
vendored
@@ -1,44 +0,0 @@
|
||||
import app from 'flarum/app';
|
||||
import SettingsPage from 'flarum/components/settings-page';
|
||||
import { extend } from 'flarum/extension-utils';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
import postMentionPreviews from 'flarum-mentions/post-mention-previews';
|
||||
import mentionedByList from 'flarum-mentions/mentioned-by-list';
|
||||
import postReplyAction from 'flarum-mentions/post-reply-action';
|
||||
import composerAutocomplete from 'flarum-mentions/composer-autocomplete';
|
||||
import PostMentionedNotification from 'flarum-mentions/components/post-mentioned-notification';
|
||||
import UserMentionedNotification from 'flarum-mentions/components/user-mentioned-notification';
|
||||
|
||||
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.
|
||||
postMentionPreviews();
|
||||
|
||||
// In the footer of each post, show information about who has replied (i.e.
|
||||
// who the post has been mentioned by).
|
||||
mentionedByList();
|
||||
|
||||
// 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.
|
||||
postReplyAction();
|
||||
|
||||
// After typing '@' in the composer, show a dropdown suggesting a bunch of
|
||||
// posts or users that the user could mention.
|
||||
composerAutocomplete();
|
||||
|
||||
app.notificationComponentRegistry['postMentioned'] = PostMentionedNotification;
|
||||
app.notificationComponentRegistry['userMentioned'] = UserMentionedNotification;
|
||||
|
||||
// Add notification preferences.
|
||||
extend(SettingsPage.prototype, 'notificationTypes', function(items) {
|
||||
items.add('postMentioned', {
|
||||
name: 'postMentioned',
|
||||
label: [icon('reply'), ' Someone replies to my post']
|
||||
});
|
||||
items.add('userMentioned', {
|
||||
name: 'userMentioned',
|
||||
label: [icon('at'), ' Someone mentions me in a post']
|
||||
});
|
||||
});
|
||||
});
|
@@ -1,7 +1,7 @@
|
||||
var gulp = require('flarum-gulp');
|
||||
|
||||
gulp({
|
||||
modulePrefix: 'flarum-mentions',
|
||||
modulePrefix: 'mentions',
|
||||
files: [
|
||||
'bower_components/textarea-caret-position/index.js'
|
||||
]
|
@@ -1,34 +1,34 @@
|
||||
import { extend } from 'flarum/extension-utils';
|
||||
import ComposerBody from 'flarum/components/composer-body';
|
||||
import ReplyComposer from 'flarum/components/reply-composer';
|
||||
import EditComposer from 'flarum/components/edit-composer';
|
||||
/*global getCaretCoordinates*/
|
||||
|
||||
import { extend } from 'flarum/extend';
|
||||
import ComposerBody from 'flarum/components/ComposerBody';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import username from 'flarum/helpers/username';
|
||||
import usernameHelper from 'flarum/helpers/username';
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
import truncate from 'flarum/utils/truncate';
|
||||
import { truncate } from 'flarum/utils/string';
|
||||
|
||||
import AutocompleteDropdown from 'flarum-mentions/components/autocomplete-dropdown';
|
||||
import AutocompleteDropdown from 'mentions/components/AutocompleteDropdown';
|
||||
|
||||
export default function() {
|
||||
extend(ComposerBody.prototype, 'config', function(original, element, isInitialized, context) {
|
||||
export default function addComposerAutocomplete() {
|
||||
extend(ComposerBody.prototype, 'config', function(original, isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
var composer = this;
|
||||
var $container = $('<div class="mentions-dropdown-container"></div>');
|
||||
var dropdown = new AutocompleteDropdown({items: []});
|
||||
var typed;
|
||||
var mentionStart;
|
||||
var $textarea = this.$('textarea');
|
||||
var searched = [];
|
||||
var searchTimeout;
|
||||
const composer = this;
|
||||
const $container = $('<div class="ComposerBody-mentionsDropdownContainer"></div>');
|
||||
const dropdown = new AutocompleteDropdown({items: []});
|
||||
const $textarea = this.$('textarea');
|
||||
const searched = [];
|
||||
let mentionStart;
|
||||
let typed;
|
||||
let searchTimeout;
|
||||
|
||||
var applySuggestion = function(replacement) {
|
||||
replacement += ' ';
|
||||
const applySuggestion = function(replacement) {
|
||||
const insert = replacement + ' ';
|
||||
|
||||
var content = composer.content();
|
||||
composer.editor.setContent(content.substring(0, mentionStart - 1)+replacement+content.substr($textarea[0].selectionStart));
|
||||
const content = composer.content();
|
||||
composer.editor.setValue(content.substring(0, mentionStart - 1) + insert + content.substr($textarea[0].selectionStart));
|
||||
|
||||
var index = mentionStart - 1 + replacement.length;
|
||||
const index = mentionStart - 1 + insert.length;
|
||||
composer.editor.setSelectionRange(index, index);
|
||||
|
||||
dropdown.hide();
|
||||
@@ -41,73 +41,76 @@ export default function() {
|
||||
// Up, down, enter, tab, escape, left, right.
|
||||
if ([9, 13, 27, 40, 38, 37, 39].indexOf(e.which) !== -1) return;
|
||||
|
||||
var cursor = this.selectionStart;
|
||||
const cursor = this.selectionStart;
|
||||
|
||||
if (this.selectionEnd - cursor > 0) return;
|
||||
|
||||
// Search backwards from the cursor for an '@' symbol, without any
|
||||
// intervening whitespace. If we find one, we will want to show the
|
||||
// autocomplete dropdown!
|
||||
var value = this.value;
|
||||
const value = this.value;
|
||||
mentionStart = 0;
|
||||
for (var i = cursor - 1; i >= 0; i--) {
|
||||
var character = value.substr(i, 1);
|
||||
for (let i = cursor - 1; i >= 0; i--) {
|
||||
const character = value.substr(i, 1);
|
||||
if (/\s/.test(character)) break;
|
||||
if (character == '@') {
|
||||
if (character === '@') {
|
||||
mentionStart = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dropdown.hide();
|
||||
dropdown.active(false);
|
||||
dropdown.active = false;
|
||||
|
||||
if (mentionStart) {
|
||||
typed = value.substring(mentionStart, cursor).toLowerCase();
|
||||
|
||||
var makeSuggestion = function(user, replacement, content, className) {
|
||||
return m('a[href=javascript:;].post-preview', {
|
||||
className,
|
||||
onclick: () => applySuggestion(replacement),
|
||||
onmouseenter: function() { dropdown.setIndex($(this).parent().index()); }
|
||||
}, m('div.post-preview-content', [
|
||||
avatar(user),
|
||||
(function() {
|
||||
var vdom = username(user);
|
||||
if (typed) {
|
||||
vdom.children[0] = highlight(vdom.children[0], typed);
|
||||
}
|
||||
return vdom;
|
||||
})(), ' ',
|
||||
content
|
||||
]));
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
var buildSuggestions = () => {
|
||||
var suggestions = [];
|
||||
const buildSuggestions = () => {
|
||||
const suggestions = [];
|
||||
|
||||
// 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.
|
||||
var composerPost = composer.props.post;
|
||||
var discussion = (composerPost && composerPost.discussion()) || composer.props.discussion;
|
||||
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 => {
|
||||
var user = post.user();
|
||||
const user = post.user();
|
||||
return user && user.username().toLowerCase().substr(0, typed.length) === typed;
|
||||
})
|
||||
.splice(0, 5)
|
||||
.forEach(post => {
|
||||
var user = post.user();
|
||||
const user = post.user();
|
||||
suggestions.push(
|
||||
makeSuggestion(user, '@'+user.username()+'#'+post.number(), [
|
||||
'Reply to #', post.number(), ' — ',
|
||||
makeSuggestion(user, '@' + user.username() + '#' + post.number(), [
|
||||
app.trans('mentions.reply_to_post', {number: post.number()}), ' — ',
|
||||
truncate(post.contentPlain(), 200)
|
||||
], 'suggestion-post')
|
||||
], 'MentionsDropdown-post')
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -119,7 +122,7 @@ export default function() {
|
||||
if (user.username().toLowerCase().substr(0, typed.length) !== typed) return;
|
||||
|
||||
suggestions.push(
|
||||
makeSuggestion(user, '@'+user.username(), '', 'suggestion-user')
|
||||
makeSuggestion(user, '@' + user.username(), '', 'MentionsDropdown-user')
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -129,12 +132,12 @@ export default function() {
|
||||
m.render($container[0], dropdown.render());
|
||||
|
||||
dropdown.show();
|
||||
var coordinates = getCaretCoordinates(this, mentionStart);
|
||||
var left = coordinates.left;
|
||||
var top = coordinates.top + 15;
|
||||
var width = dropdown.$().outerWidth();
|
||||
var height = dropdown.$().outerHeight();
|
||||
var parent = dropdown.$().offsetParent();
|
||||
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;
|
||||
}
|
||||
@@ -149,15 +152,15 @@ export default function() {
|
||||
|
||||
dropdown.setIndex(0);
|
||||
dropdown.$().scrollTop(0);
|
||||
dropdown.active(true);
|
||||
dropdown.active = true;
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
if (typed) {
|
||||
searchTimeout = setTimeout(function() {
|
||||
var typedLower = typed.toLowerCase();
|
||||
const typedLower = typed.toLowerCase();
|
||||
if (searched.indexOf(typedLower) === -1) {
|
||||
app.store.find('users', {q: typed, page: {limit: 5}}).then(users => {
|
||||
if (dropdown.active()) buildSuggestions();
|
||||
app.store.find('users', {q: typed, page: {limit: 5}}).then(() => {
|
||||
if (dropdown.active) buildSuggestions();
|
||||
});
|
||||
searched.push(typedLower);
|
||||
}
|
112
extensions/mentions/js/forum/src/addMentionedByList.js
Normal file
112
extensions/mentions/js/forum/src/addMentionedByList.js
Normal file
@@ -0,0 +1,112 @@
|
||||
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 punctuate from 'flarum/helpers/punctuate';
|
||||
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');
|
||||
});
|
||||
};
|
||||
|
||||
// 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 used = [];
|
||||
const repliers = replies.filter(reply => {
|
||||
const user = reply.user();
|
||||
const id = user && user.id();
|
||||
if (used.indexOf(id) === -1) {
|
||||
used.push(id);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
const names = repliers.sort(a => a === app.session.user ? -1 : 1)
|
||||
.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.trans('mentions.you') : username(user)}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
items.add('replies',
|
||||
<div className="Post-mentionedBy" config={config}>
|
||||
<span className="Post-mentionedBy-summary">
|
||||
{icon('reply')}
|
||||
{app.trans('mentions.post_mentioned_by', {
|
||||
count: names.length,
|
||||
users: punctuate(names)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
@@ -1,40 +1,46 @@
|
||||
import { extend } from 'flarum/extension-utils';
|
||||
import CommentPost from 'flarum/components/comment-post';
|
||||
import PostPreview from 'flarum/components/post-preview';
|
||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
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 postMentionPreviews() {
|
||||
export default function addPostMentionPreviews() {
|
||||
extend(CommentPost.prototype, 'config', function() {
|
||||
var contentHtml = this.props.post.contentHtml();
|
||||
const contentHtml = this.props.post.contentHtml();
|
||||
|
||||
if (contentHtml === this.oldPostContentHtml) return;
|
||||
|
||||
this.oldPostContentHtml = contentHtml;
|
||||
|
||||
var discussion = this.props.post.discussion();
|
||||
const discussion = this.props.post.discussion();
|
||||
|
||||
this.$('.mention-post').each(function() {
|
||||
var $this = $(this);
|
||||
var number = $this.data('number');
|
||||
var timeout;
|
||||
this.$('.UserMention').each(function() {
|
||||
m.route.call(this, this, false, {}, {attrs: {href: this.getAttribute('href')}});
|
||||
});
|
||||
|
||||
this.$('.PostMention').each(function() {
|
||||
const $this = $(this);
|
||||
const number = $this.data('number');
|
||||
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.
|
||||
var $preview = $('<ul class="dropdown-menu mention-post-preview fade"/>');
|
||||
var $wrapper = $('<span class="mention-post-wrapper"/>');
|
||||
const $preview = $('<ul class="Dropdown-menu PostMention-preview fade"/>');
|
||||
const $wrapper = $('<span class="PostMention-wrapper"/>');
|
||||
$this.wrap($wrapper).after($preview);
|
||||
|
||||
var getPostElement = function() {
|
||||
return $('.discussion-posts .item[data-number='+number+']');
|
||||
const getPostElement = () => {
|
||||
return $(`.PostStream-item[data-number="${number}"]`);
|
||||
};
|
||||
|
||||
var showPreview = function() {
|
||||
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.
|
||||
var $post = getPostElement();
|
||||
var visible = false;
|
||||
const $post = getPostElement();
|
||||
let visible = false;
|
||||
if ($post.length) {
|
||||
var top = $post.offset().top;
|
||||
var scrollTop = window.pageYOffset;
|
||||
const top = $post.offset().top;
|
||||
const scrollTop = window.pageYOffset;
|
||||
if (top > scrollTop && top + $post.height() < scrollTop + $(window).height()) {
|
||||
$post.addClass('pulsate');
|
||||
visible = true;
|
||||
@@ -44,30 +50,33 @@ export default function postMentionPreviews() {
|
||||
// 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) {
|
||||
var showPost = function(post) {
|
||||
m.render($preview[0], m('li', PostPreview.component({post})));
|
||||
positionPreview();
|
||||
};
|
||||
|
||||
// Position the preview so that it appears above the mention.
|
||||
// (The offsetParent should be .post-body.)
|
||||
var positionPreview = function() {
|
||||
const positionPreview = () => {
|
||||
$preview.show().css('top', $this.offset().top - $this.offsetParent().offset().top - $preview.outerHeight(true));
|
||||
};
|
||||
|
||||
var post = discussion.posts().filter(post => post && post.number() == number)[0];
|
||||
const showPost = post => {
|
||||
m.render($preview[0], <li>{PostPreview.component({post})}</li>);
|
||||
positionPreview();
|
||||
};
|
||||
|
||||
const post = discussion.posts().filter(p => p && p.number() === number)[0];
|
||||
if (post) {
|
||||
showPost(post);
|
||||
} else {
|
||||
m.render($preview[0], LoadingIndicator.component());
|
||||
app.store.find('posts', {discussions: discussion.id(), number}).then(posts => showPost(posts[0]));
|
||||
app.store.find('posts', {
|
||||
filter: {discussion: discussion.id(), number}
|
||||
}).then(posts => showPost(posts[0]));
|
||||
positionPreview();
|
||||
}
|
||||
|
||||
setTimeout(() => $preview.off('transitionend').addClass('in'));
|
||||
}
|
||||
};
|
||||
var hidePreview = () => {
|
||||
|
||||
const hidePreview = () => {
|
||||
getPostElement().removeClass('pulsate');
|
||||
if ($preview.hasClass('in')) {
|
||||
$preview.removeClass('in').one('transitionend', () => $preview.hide());
|
||||
@@ -75,11 +84,11 @@ export default function postMentionPreviews() {
|
||||
};
|
||||
|
||||
$this.parent().hover(
|
||||
function() {
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(showPreview, 500);
|
||||
},
|
||||
function() {
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
getPostElement().removeClass('pulsate');
|
||||
timeout = setTimeout(hidePreview, 250);
|
48
extensions/mentions/js/forum/src/addPostReplyAction.js
Normal file
48
extensions/mentions/js/forum/src/addPostReplyAction.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { extend } from 'flarum/extend';
|
||||
import Button from 'flarum/components/Button';
|
||||
import CommentPost from 'flarum/components/CommentPost';
|
||||
import DiscussionControls from 'flarum/utils/DiscussionControls';
|
||||
|
||||
export default function() {
|
||||
extend(CommentPost.prototype, 'actionItems', function(items) {
|
||||
const post = this.props.post;
|
||||
|
||||
if (post.isHidden() || (app.session.user && !post.discussion().canReply())) return;
|
||||
|
||||
function insertMention(component, quote) {
|
||||
const mention = '@' + post.user().username() + '#' + post.number() + ' ';
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
component.editor.insertAtCursor(
|
||||
(component.editor.getSelectionRange()[0] > 0 ? '\n\n' : '') +
|
||||
(quote
|
||||
? '> ' + mention + quote.trim().replace(/\n/g, '\n> ') + '\n\n'
|
||||
: mention)
|
||||
);
|
||||
}
|
||||
|
||||
items.add('reply',
|
||||
Button.component({
|
||||
className: 'Button Button--text',
|
||||
children: app.trans('mentions.reply_link'),
|
||||
onclick: () => {
|
||||
const quote = window.getSelection().toString();
|
||||
|
||||
const component = app.composer.component;
|
||||
if (component && component.props.post && component.props.post.discussion() === post.discussion()) {
|
||||
insertMention(component, quote);
|
||||
} else {
|
||||
DiscussionControls.replyAction.call(post.discussion())
|
||||
.then(newComponent => insertMention(newComponent, quote));
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
@@ -0,0 +1,97 @@
|
||||
import Component from 'flarum/Component';
|
||||
|
||||
export default class AutocompleteDropdown extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
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(e) {
|
||||
if (!this.active) return;
|
||||
|
||||
switch (e.which) {
|
||||
case 40: case 38: // Down/Up
|
||||
this.keyWasJustPressed = true;
|
||||
this.setIndex(this.index + (e.which === 40 ? 1 : -1), true);
|
||||
clearTimeout(this.keyWasJustPressedTimeout);
|
||||
this.keyWasJustPressedTimeout = setTimeout(() => this.keyWasJustPressed = false, 500);
|
||||
e.preventDefault();
|
||||
break;
|
||||
|
||||
case 13: case 9: // Enter/Tab
|
||||
this.$('li').eq(this.index).find('button').click();
|
||||
e.preventDefault();
|
||||
break;
|
||||
|
||||
case 27: // Escape
|
||||
this.hide();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
break;
|
||||
|
||||
default:
|
||||
// no default
|
||||
}
|
||||
}
|
||||
|
||||
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,34 @@
|
||||
import Notification from 'flarum/components/Notification';
|
||||
import username from 'flarum/helpers/username';
|
||||
import punctuate from 'flarum/helpers/punctuate';
|
||||
|
||||
export default class PostMentionedNotification extends Notification {
|
||||
icon() {
|
||||
return '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 post = notification.subject();
|
||||
const auc = notification.additionalUnreadCount();
|
||||
const user = notification.sender();
|
||||
|
||||
return app.trans('mentions.post_mentioned_notification', {
|
||||
user,
|
||||
username: auc ? punctuate([
|
||||
username(user),
|
||||
app.trans('mentions.others', {count: auc})
|
||||
]) : undefined,
|
||||
number: post.number()
|
||||
});
|
||||
}
|
||||
}
|
@@ -0,0 +1,20 @@
|
||||
import Notification from 'flarum/components/Notification';
|
||||
import username from 'flarum/helpers/username';
|
||||
|
||||
export default class UserMentionedNotification extends Notification {
|
||||
icon() {
|
||||
return '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.trans('mentions.user_mentioned_notification', {user});
|
||||
}
|
||||
}
|
45
extensions/mentions/js/forum/src/main.js
Normal file
45
extensions/mentions/js/forum/src/main.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { extend } from 'flarum/extend';
|
||||
import app from 'flarum/app';
|
||||
import NotificationGrid from 'flarum/components/NotificationGrid';
|
||||
|
||||
import addPostMentionPreviews from 'mentions/addPostMentionPreviews';
|
||||
import addMentionedByList from 'mentions/addMentionedByList';
|
||||
import addPostReplyAction from 'mentions/addPostReplyAction';
|
||||
import addComposerAutocomplete from 'mentions/addComposerAutocomplete';
|
||||
import PostMentionedNotification from 'mentions/components/PostMentionedNotification';
|
||||
import UserMentionedNotification from 'mentions/components/UserMentionedNotification';
|
||||
|
||||
app.initializers.add('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();
|
||||
|
||||
// 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: 'reply',
|
||||
label: 'Someone replies to my post'
|
||||
});
|
||||
items.add('userMentioned', {
|
||||
name: 'userMentioned',
|
||||
icon: 'at',
|
||||
label: 'Someone mentions me in a post'
|
||||
});
|
||||
});
|
||||
});
|
@@ -1,89 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
|
||||
export default class AutocompleteDropdown extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.active = m.prop(false);
|
||||
this.index = m.prop(0);
|
||||
this.keyWasJustPressed = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
return m('ul.dropdown-menu.mentions-dropdown', this.props.items.map(item => m('li', item)));
|
||||
}
|
||||
|
||||
show(left, top) {
|
||||
this.$().show().css({
|
||||
left: left+'px',
|
||||
top: top+'px'
|
||||
});
|
||||
this.active(true);
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.$().hide();
|
||||
this.active(false);
|
||||
}
|
||||
|
||||
navigate(e) {
|
||||
if (!this.active()) return;
|
||||
|
||||
switch (e.which) {
|
||||
case 40: case 38: // Down/Up
|
||||
this.keyWasJustPressed = true;
|
||||
this.setIndex(this.index() + (e.which === 40 ? 1 : -1), true);
|
||||
clearTimeout(this.keyWasJustPressedTimeout);
|
||||
this.keyWasJustPressedTimeout = setTimeout(() => this.keyWasJustPressed = false, 500);
|
||||
e.preventDefault();
|
||||
break;
|
||||
|
||||
case 13: case 9: // Enter/Tab
|
||||
this.$('li').eq(this.index()).find('a').click();
|
||||
e.preventDefault();
|
||||
break;
|
||||
|
||||
case 27: // Escape
|
||||
this.hide();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setIndex(index, scrollToItem) {
|
||||
if (this.keyWasJustPressed && !scrollToItem) return;
|
||||
|
||||
var $dropdown = this.$();
|
||||
var $items = $dropdown.find('li');
|
||||
|
||||
if (index < 0) {
|
||||
index = $items.length - 1;
|
||||
} else if (index >= $items.length) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
this.index(index);
|
||||
|
||||
var $item = $items.removeClass('active').eq(index).addClass('active');
|
||||
|
||||
if (scrollToItem) {
|
||||
var dropdownScroll = $dropdown.scrollTop();
|
||||
var dropdownTop = $dropdown.offset().top;
|
||||
var dropdownBottom = dropdownTop + $dropdown.outerHeight();
|
||||
var itemTop = $item.offset().top;
|
||||
var itemBottom = itemTop + $item.outerHeight();
|
||||
|
||||
var scrollTop;
|
||||
if (itemTop < dropdownTop) {
|
||||
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'));
|
||||
} else if (itemBottom > dropdownBottom) {
|
||||
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'));
|
||||
}
|
||||
|
||||
if (typeof scrollTop !== 'undefined') {
|
||||
$dropdown.stop(true).animate({scrollTop}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,17 +0,0 @@
|
||||
import Notification from 'flarum/components/notification';
|
||||
import username from 'flarum/helpers/username';
|
||||
|
||||
export default class PostMentionedNotification extends Notification {
|
||||
view() {
|
||||
var notification = this.props.notification;
|
||||
var post = notification.subject();
|
||||
var auc = notification.additionalUnreadCount();
|
||||
var content = notification.content();
|
||||
|
||||
return super.view({
|
||||
href: app.route.discussion(post.discussion(), auc ? post.number() : (content && content.replyNumber)),
|
||||
icon: 'reply',
|
||||
content: [username(notification.sender()), (auc ? ' and '+auc+' others' : '')+' replied to your post #'+post.number()]
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,15 +0,0 @@
|
||||
import Notification from 'flarum/components/notification';
|
||||
import username from 'flarum/helpers/username';
|
||||
|
||||
export default class UserMentionedNotification extends Notification {
|
||||
view() {
|
||||
var notification = this.props.notification;
|
||||
var post = notification.subject();
|
||||
|
||||
return super.view({
|
||||
href: app.route.discussion(post.discussion(), post.number()),
|
||||
icon: 'at',
|
||||
content: [username(notification.sender()), ' mentioned you']
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,102 +0,0 @@
|
||||
import { extend } from 'flarum/extension-utils';
|
||||
import Model from 'flarum/model';
|
||||
import Post from 'flarum/models/post';
|
||||
import DiscussionPage from 'flarum/components/discussion-page';
|
||||
import CommentPost from 'flarum/components/comment-post';
|
||||
import PostPreview from 'flarum/components/post-preview';
|
||||
import punctuate from 'flarum/helpers/punctuate';
|
||||
import username from 'flarum/helpers/username';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
export default function mentionedByList() {
|
||||
Post.prototype.mentionedBy = Model.many('mentionedBy');
|
||||
|
||||
extend(DiscussionPage.prototype, 'params', function(params) {
|
||||
params.include.push('posts.mentionedBy', 'posts.mentionedBy.user');
|
||||
});
|
||||
|
||||
extend(CommentPost.prototype, 'footerItems', function(items) {
|
||||
var post = this.props.post;
|
||||
var 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;
|
||||
}
|
||||
|
||||
var hidePreview = () => {
|
||||
this.$('.mentioned-by-preview').removeClass('in').one('transitionend', function() { $(this).hide(); });
|
||||
};
|
||||
|
||||
var config = function(element, isInitialized) {
|
||||
if (isInitialized) return;
|
||||
var $this = $(element);
|
||||
var timeout;
|
||||
|
||||
var $preview = $('<ul class="dropdown-menu mentioned-by-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(post => {
|
||||
return m('li', {'data-number': post.number()}, PostPreview.component({post, onclick: hidePreview}));
|
||||
}));
|
||||
$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('.summary a').hover(function() {
|
||||
$preview.find('[data-number='+$(this).data('number')+']').addClass('active');
|
||||
}, function() {
|
||||
$preview.find('[data-number]').removeClass('active');
|
||||
});
|
||||
};
|
||||
|
||||
// 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.
|
||||
var used = [];
|
||||
var repliers = replies.filter(reply => {
|
||||
var user = reply.user();
|
||||
var id = user && user.id();
|
||||
if (used.indexOf(id) === -1) {
|
||||
used.push(id);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
items.add('replies',
|
||||
m('div.mentioned-by', {config}, [
|
||||
m('span.summary', [
|
||||
icon('reply icon'),
|
||||
punctuate(repliers.map(reply => {
|
||||
return m('a', {
|
||||
href: app.route.post(reply),
|
||||
config: m.route,
|
||||
onclick: hidePreview,
|
||||
'data-number': reply.number()
|
||||
}, [
|
||||
app.session.user() && reply.user() === app.session.user() ? 'You' : username(reply.user())
|
||||
])
|
||||
})),
|
||||
' replied to this.'
|
||||
])
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
@@ -1,40 +0,0 @@
|
||||
import { extend } from 'flarum/extension-utils';
|
||||
import ActionButton from 'flarum/components/action-button';
|
||||
import CommentPost from 'flarum/components/comment-post';
|
||||
|
||||
export default function() {
|
||||
extend(CommentPost.prototype, 'actionItems', function(items) {
|
||||
var post = this.props.post;
|
||||
if (post.isHidden() || (app.session.user() && !post.discussion().canReply())) return;
|
||||
|
||||
function insertMention(component, quote) {
|
||||
var mention = '@'+post.user().username()+'#'+post.number()+' ';
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
component.editor.insertAtCursor((component.editor.getSelectionRange()[0] > 0 ? '\n\n' : '')+(quote ? '> '+mention+quote.trim().replace(/\n/g, '\n> ')+'\n\n' : mention));
|
||||
}
|
||||
|
||||
items.add('reply',
|
||||
ActionButton.component({
|
||||
icon: 'reply',
|
||||
label: 'Reply',
|
||||
onclick: () => {
|
||||
var quote = window.getSelection().toString();
|
||||
|
||||
var component = app.composer.component;
|
||||
if (component && component.props.post && component.props.post.discussion() === post.discussion()) {
|
||||
insertMention(component, quote);
|
||||
} else {
|
||||
post.discussion().replyAction().then(component => insertMention(component, quote));
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user