diff --git a/extensions/mentions/.gitignore b/extensions/mentions/.gitignore index a4f3b125e..43eeee7fe 100644 --- a/extensions/mentions/.gitignore +++ b/extensions/mentions/.gitignore @@ -2,3 +2,5 @@ composer.phar .DS_Store Thumbs.db +bower_components +node_modules \ No newline at end of file diff --git a/extensions/mentions/bootstrap.php b/extensions/mentions/bootstrap.php index 1842a192e..f4728df85 100644 --- a/extensions/mentions/bootstrap.php +++ b/extensions/mentions/bootstrap.php @@ -9,6 +9,17 @@ * file that was distributed with this source code. */ -require __DIR__.'/vendor/autoload.php'; +use Flarum\Mentions\Listener; +use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Contracts\View\Factory; -return 'Flarum\Mentions\Extension'; +return function (Dispatcher $events, Factory $views) { + $events->subscribe(Listener\AddClientAssets::class); + $events->subscribe(Listener\AddPostMentionedByRelationship::class); + $events->subscribe(Listener\FormatPostMentions::class); + $events->subscribe(Listener\FormatUserMentions::class); + $events->subscribe(Listener\UpdatePostMentionsMetadata::class); + $events->subscribe(Listener\UpdateUserMentionsMetadata::class); + + $views->addNamespace('flarum-mentions', __DIR__.'/views'); +}; diff --git a/extensions/mentions/composer.json b/extensions/mentions/composer.json index 2f43bbab9..d88b5efb7 100644 --- a/extensions/mentions/composer.json +++ b/extensions/mentions/composer.json @@ -1,10 +1,34 @@ { + "name": "flarum/mentions", + "description": "Mention and reply to specific posts and users.", + "type": "flarum-extension", + "license": "MIT", + "authors": [ + { + "name": "Toby Zerner", + "email": "toby.zerner@gmail.com" + } + ], + "support": { + "issues": "https://github.com/flarum/core/issues", + "source": "https://github.com/flarum/mentions" + }, + "require": { + "flarum/core": "^0.1.0-beta.3" + }, "autoload": { "psr-4": { "Flarum\\Mentions\\": "src/" } }, - "scripts": { - "style": "phpcs --standard=PSR2 -np src" + "extra": { + "flarum-extension": { + "title": "Mentions", + "icon": { + "name": "at", + "backgroundColor": "#539EC1", + "color": "#fff" + } + } } } diff --git a/extensions/mentions/flarum.json b/extensions/mentions/flarum.json deleted file mode 100644 index 64a4e89c8..000000000 --- a/extensions/mentions/flarum.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "mentions", - "title": "Mentions", - "description": "Mention and reply to specific posts and users.", - "keywords": ["discussions"], - "version": "0.1.0-beta.2", - "author": { - "name": "Toby Zerner", - "email": "toby@flarum.org", - "homepage": "http://tobyzerner.com" - }, - "license": "MIT", - "require": { - "flarum": ">=0.1.0-beta.2" - }, - "support": { - "source": "https://github.com/flarum/mentions", - "issues": "https://github.com/flarum/core/issues" - }, - "icon": { - "name": "at", - "backgroundColor": "#539EC1", - "color": "#fff" - } -} diff --git a/extensions/mentions/js/.gitignore b/extensions/mentions/js/.gitignore deleted file mode 100644 index bae304483..000000000 --- a/extensions/mentions/js/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -bower_components -node_modules -mithril.js -dist diff --git a/extensions/mentions/js/forum/Gulpfile.js b/extensions/mentions/js/forum/Gulpfile.js index 15f00f213..10457c13c 100644 --- a/extensions/mentions/js/forum/Gulpfile.js +++ b/extensions/mentions/js/forum/Gulpfile.js @@ -2,7 +2,7 @@ var gulp = require('flarum-gulp'); gulp({ modules: { - 'mentions': 'src/**/*.js' + 'flarum/mentions': 'src/**/*.js' }, files: [ 'bower_components/textarea-caret-position/index.js' diff --git a/extensions/mentions/js/forum/dist/extension.js b/extensions/mentions/js/forum/dist/extension.js new file mode 100644 index 000000000..7d5b2fa04 --- /dev/null +++ b/extensions/mentions/js/forum/dist/extension.js @@ -0,0 +1,955 @@ +/* jshint browser: true */ + +(function () { + +// The properties that we copy into a mirrored div. +// Note that some browsers, such as Firefox, +// do not concatenate properties, i.e. padding-top, bottom etc. -> padding, +// so we have to do every single property specifically. +var properties = [ + 'direction', // RTL support + 'boxSizing', + 'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does + 'height', + 'overflowX', + 'overflowY', // copy the scrollbar for IE + + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + 'borderStyle', + + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + + // https://developer.mozilla.org/en-US/docs/Web/CSS/font + 'fontStyle', + 'fontVariant', + 'fontWeight', + 'fontStretch', + 'fontSize', + 'fontSizeAdjust', + 'lineHeight', + 'fontFamily', + + 'textAlign', + 'textTransform', + 'textIndent', + 'textDecoration', // might not make a difference, but better be safe + + 'letterSpacing', + 'wordSpacing', + + 'tabSize', + 'MozTabSize' + +]; + +var isFirefox = window.mozInnerScreenX != null; + +function getCaretCoordinates(element, position) { + // mirrored div + var div = document.createElement('div'); + div.id = 'input-textarea-caret-position-mirror-div'; + document.body.appendChild(div); + + var style = div.style; + var computed = window.getComputedStyle? getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9 + + // default textarea styles + style.whiteSpace = 'pre-wrap'; + if (element.nodeName !== 'INPUT') + style.wordWrap = 'break-word'; // only for textarea-s + + // position off-screen + style.position = 'absolute'; // required to return coordinates properly + style.visibility = 'hidden'; // not 'display: none' because we want rendering + + // transfer the element's properties to the div + properties.forEach(function (prop) { + style[prop] = computed[prop]; + }); + + if (isFirefox) { + // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275 + if (element.scrollHeight > parseInt(computed.height)) + style.overflowY = 'scroll'; + } else { + style.overflow = 'hidden'; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll' + } + + div.textContent = element.value.substring(0, position); + // the second special handling for input type="text" vs textarea: spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037 + if (element.nodeName === 'INPUT') + div.textContent = div.textContent.replace(/\s/g, "\u00a0"); + + var span = document.createElement('span'); + // Wrapping must be replicated *exactly*, including when a long word gets + // onto the next line, with whitespace at the end of the line before (#7). + // The *only* reliable way to do that is to copy the *entire* rest of the + // textarea's content into the created at the caret position. + // for inputs, just '.' would be enough, but why bother? + span.textContent = element.value.substring(position) || '.'; // || because a completely empty faux span doesn't render at all + div.appendChild(span); + + var coordinates = { + top: span.offsetTop + parseInt(computed['borderTopWidth']), + left: span.offsetLeft + parseInt(computed['borderLeftWidth']) + }; + + document.body.removeChild(div); + + return coordinates; +} + +if (typeof module != "undefined" && typeof module.exports != "undefined") { + module.exports = getCaretCoordinates; +} else { + window.getCaretCoordinates = getCaretCoordinates; +} + +}()); +;System.register('flarum/mentions/addComposerAutocomplete', ['flarum/extend', 'flarum/components/ComposerBody', 'flarum/helpers/avatar', 'flarum/helpers/username', 'flarum/helpers/highlight', 'flarum/utils/string', 'flarum/mentions/components/AutocompleteDropdown'], function (_export) { + /*global getCaretCoordinates*/ + + 'use strict'; + + var extend, ComposerBody, avatar, usernameHelper, highlight, truncate, AutocompleteDropdown; + + _export('default', addComposerAutocomplete); + + function addComposerAutocomplete() { + extend(ComposerBody.prototype, 'config', function (original, isInitialized) { + if (isInitialized) return; + + var composer = this; + var $container = $('
'); + var dropdown = new AutocompleteDropdown({ items: [] }); + var $textarea = this.$('textarea'); + var searched = []; + var mentionStart = undefined; + var typed = undefined; + var searchTimeout = undefined; + + var applySuggestion = function applySuggestion(replacement) { + var insert = replacement + ' '; + + var content = composer.content(); + composer.editor.setValue(content.substring(0, mentionStart - 1) + insert + content.substr($textarea[0].selectionStart)); + + var index = mentionStart - 1 + insert.length; + composer.editor.setSelectionRange(index, index); + + dropdown.hide(); + }; + + $textarea.after($container).on('keydown', dropdown.navigate.bind(dropdown)).on('click keyup', function (e) { + var _this = this; + + // Up, down, enter, tab, escape, left, right. + if ([9, 13, 27, 40, 38, 37, 39].indexOf(e.which) !== -1) return; + + var 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; + mentionStart = 0; + for (var i = cursor - 1; i >= 0; i--) { + var character = value.substr(i, 1); + if (/\s/.test(character)) break; + if (character === '@') { + mentionStart = i + 1; + break; + } + } + + dropdown.hide(); + dropdown.active = false; + + if (mentionStart) { + (function () { + typed = value.substring(mentionStart, cursor).toLowerCase(); + + var makeSuggestion = function makeSuggestion(user, replacement, content) { + var className = arguments.length <= 3 || arguments[3] === undefined ? '' : arguments[3]; + + var username = usernameHelper(user); + if (typed) { + username.children[0] = highlight(username.children[0], typed); + } + + return m( + 'button', + { className: 'PostPreview ' + className, + onclick: function () { + return applySuggestion(replacement); + }, + onmouseenter: function () { + dropdown.setIndex($(this).parent().index()); + } }, + m( + 'span', + { className: 'PostPreview-content' }, + avatar(user), + username, + ' ', + ' ', + content + ) + ); + }; + + var buildSuggestions = function buildSuggestions() { + var 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; + if (discussion) { + discussion.posts().filter(function (post) { + return post && post.contentType() === 'comment' && (!composerPost || post.number() < composerPost.number()); + }).sort(function (a, b) { + return b.time() - a.time(); + }).filter(function (post) { + var user = post.user(); + return user && user.username().toLowerCase().substr(0, typed.length) === typed; + }).splice(0, 5).forEach(function (post) { + var user = post.user(); + suggestions.push(makeSuggestion(user, '@' + user.username() + '#' + post.id(), [app.trans('flarum-mentions.forum.reply_to_post', { number: post.number() }), ' — ', truncate(post.contentPlain(), 200)], 'MentionsDropdown-post')); + }); + } + + // If the user has started to type a username, then suggest users + // matching that username. + if (typed) { + app.store.all('users').forEach(function (user) { + if (user.username().toLowerCase().substr(0, typed.length) !== typed) return; + + suggestions.push(makeSuggestion(user, '@' + user.username(), '', 'MentionsDropdown-user')); + }); + } + + if (suggestions.length) { + dropdown.props.items = suggestions; + m.render($container[0], dropdown.render()); + + dropdown.show(); + var coordinates = getCaretCoordinates(_this, mentionStart); + var width = dropdown.$().outerWidth(); + var height = dropdown.$().outerHeight(); + var _parent = dropdown.$().offsetParent(); + var left = coordinates.left; + var _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); + } + }; + + buildSuggestions(); + + dropdown.setIndex(0); + dropdown.$().scrollTop(0); + dropdown.active = true; + + clearTimeout(searchTimeout); + if (typed) { + searchTimeout = setTimeout(function () { + var typedLower = typed.toLowerCase(); + if (searched.indexOf(typedLower) === -1) { + app.store.find('users', { q: typed, page: { limit: 5 } }).then(function () { + if (dropdown.active) buildSuggestions(); + }); + searched.push(typedLower); + } + }, 250); + } + })(); + } + }); + }); + } + + return { + setters: [function (_flarumExtend) { + extend = _flarumExtend.extend; + }, function (_flarumComponentsComposerBody) { + ComposerBody = _flarumComponentsComposerBody['default']; + }, function (_flarumHelpersAvatar) { + avatar = _flarumHelpersAvatar['default']; + }, function (_flarumHelpersUsername) { + usernameHelper = _flarumHelpersUsername['default']; + }, function (_flarumHelpersHighlight) { + highlight = _flarumHelpersHighlight['default']; + }, function (_flarumUtilsString) { + truncate = _flarumUtilsString.truncate; + }, function (_flarumMentionsComponentsAutocompleteDropdown) { + AutocompleteDropdown = _flarumMentionsComponentsAutocompleteDropdown['default']; + }], + execute: function () {} + }; +});;System.register('flarum/mentions/addMentionedByList', ['flarum/extend', 'flarum/Model', 'flarum/models/Post', 'flarum/components/CommentPost', 'flarum/components/PostPreview', 'flarum/helpers/punctuateSeries', 'flarum/helpers/username', 'flarum/helpers/icon'], function (_export) { + 'use strict'; + + var extend, Model, Post, CommentPost, PostPreview, punctuateSeries, username, icon; + + _export('default', addMentionedByList); + + function addMentionedByList() { + Post.prototype.mentionedBy = Model.hasMany('mentionedBy'); + + extend(CommentPost.prototype, 'footerItems', function (items) { + var _this = this; + + var post = this.props.post; + var replies = post.mentionedBy(); + + if (replies && replies.length) { + var _ret = (function () { + // 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 { + v: undefined + }; + } + + var hidePreview = function hidePreview() { + _this.$('.Post-mentionedBy-preview').removeClass('in').one('transitionend', function () { + $(this).hide(); + }); + }; + + var config = function config(element, isInitialized) { + if (isInitialized) return; + + var $this = $(element); + var timeout = undefined; + + var $preview = $('