diff --git a/extensions/mentions/.editorconfig b/extensions/mentions/.editorconfig new file mode 100644 index 000000000..5612a5e74 --- /dev/null +++ b/extensions/mentions/.editorconfig @@ -0,0 +1,32 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.js] +indent_style = space +indent_size = 2 + +[*.{css,less}] +indent_style = space +indent_size = 2 + +[*.html] +indent_style = space +indent_size = 2 + +[*.{diff,md}] +trim_trailing_whitespace = false + +[*.php] +indent_style = space +indent_size = 4 diff --git a/extensions/mentions/.eslintignore b/extensions/mentions/.eslintignore new file mode 100644 index 000000000..86b7c8854 --- /dev/null +++ b/extensions/mentions/.eslintignore @@ -0,0 +1,5 @@ +**/bower_components/**/* +**/node_modules/**/* +vendor/**/* +**/Gulpfile.js +**/dist/**/* diff --git a/extensions/mentions/.eslintrc b/extensions/mentions/.eslintrc new file mode 100644 index 000000000..9cebc759d --- /dev/null +++ b/extensions/mentions/.eslintrc @@ -0,0 +1,171 @@ +{ + "parser": "babel-eslint", // https://github.com/babel/babel-eslint + "env": { // http://eslint.org/docs/user-guide/configuring.html#specifying-environments + "browser": true // browser global variables + }, + "ecmaFeatures": { + "arrowFunctions": true, + "blockBindings": true, + "classes": true, + "defaultParams": true, + "destructuring": true, + "forOf": true, + "generators": false, + "modules": true, + "objectLiteralComputedProperties": true, + "objectLiteralDuplicateProperties": false, + "objectLiteralShorthandMethods": true, + "objectLiteralShorthandProperties": true, + "spread": true, + "superInFunctions": true, + "templateStrings": true, + "jsx": true + }, + "globals": { + "m": true, + "app": true, + "$": true, + "moment": true + }, + "rules": { +/** + * Strict mode + */ + // babel inserts "use strict"; for us + "strict": [2, "never"], // http://eslint.org/docs/rules/strict + +/** + * ES6 + */ + "no-var": 2, // http://eslint.org/docs/rules/no-var + "prefer-const": 2, // http://eslint.org/docs/rules/prefer-const + +/** + * Variables + */ + "no-shadow": 2, // http://eslint.org/docs/rules/no-shadow + "no-shadow-restricted-names": 2, // http://eslint.org/docs/rules/no-shadow-restricted-names + "no-unused-vars": [2, { // http://eslint.org/docs/rules/no-unused-vars + "vars": "local", + "args": "after-used" + }], + "no-use-before-define": 2, // http://eslint.org/docs/rules/no-use-before-define + +/** + * Possible errors + */ + "comma-dangle": [2, "never"], // http://eslint.org/docs/rules/comma-dangle + "no-cond-assign": [2, "always"], // http://eslint.org/docs/rules/no-cond-assign + "no-console": 1, // http://eslint.org/docs/rules/no-console + "no-debugger": 1, // http://eslint.org/docs/rules/no-debugger + "no-alert": 1, // http://eslint.org/docs/rules/no-alert + "no-constant-condition": 1, // http://eslint.org/docs/rules/no-constant-condition + "no-dupe-keys": 2, // http://eslint.org/docs/rules/no-dupe-keys + "no-duplicate-case": 2, // http://eslint.org/docs/rules/no-duplicate-case + "no-empty": 2, // http://eslint.org/docs/rules/no-empty + "no-ex-assign": 2, // http://eslint.org/docs/rules/no-ex-assign + "no-extra-boolean-cast": 0, // http://eslint.org/docs/rules/no-extra-boolean-cast + "no-extra-semi": 2, // http://eslint.org/docs/rules/no-extra-semi + "no-func-assign": 2, // http://eslint.org/docs/rules/no-func-assign + "no-inner-declarations": 2, // http://eslint.org/docs/rules/no-inner-declarations + "no-invalid-regexp": 2, // http://eslint.org/docs/rules/no-invalid-regexp + "no-irregular-whitespace": 2, // http://eslint.org/docs/rules/no-irregular-whitespace + "no-obj-calls": 2, // http://eslint.org/docs/rules/no-obj-calls + "no-reserved-keys": 2, // http://eslint.org/docs/rules/no-reserved-keys + "no-sparse-arrays": 2, // http://eslint.org/docs/rules/no-sparse-arrays + "no-unreachable": 2, // http://eslint.org/docs/rules/no-unreachable + "use-isnan": 2, // http://eslint.org/docs/rules/use-isnan + "block-scoped-var": 2, // http://eslint.org/docs/rules/block-scoped-var + +/** + * Best practices + */ + "consistent-return": 2, // http://eslint.org/docs/rules/consistent-return + "curly": [2, "multi-line"], // http://eslint.org/docs/rules/curly + "default-case": 2, // http://eslint.org/docs/rules/default-case + "dot-notation": [2, { // http://eslint.org/docs/rules/dot-notation + "allowKeywords": true + }], + "eqeqeq": 2, // http://eslint.org/docs/rules/eqeqeq + "no-caller": 2, // http://eslint.org/docs/rules/no-caller + "no-else-return": 2, // http://eslint.org/docs/rules/no-else-return + "no-eq-null": 2, // http://eslint.org/docs/rules/no-eq-null + "no-eval": 2, // http://eslint.org/docs/rules/no-eval + "no-extend-native": 2, // http://eslint.org/docs/rules/no-extend-native + "no-extra-bind": 2, // http://eslint.org/docs/rules/no-extra-bind + "no-fallthrough": 2, // http://eslint.org/docs/rules/no-fallthrough + "no-floating-decimal": 2, // http://eslint.org/docs/rules/no-floating-decimal + "no-implied-eval": 2, // http://eslint.org/docs/rules/no-implied-eval + "no-lone-blocks": 2, // http://eslint.org/docs/rules/no-lone-blocks + "no-loop-func": 2, // http://eslint.org/docs/rules/no-loop-func + "no-multi-str": 2, // http://eslint.org/docs/rules/no-multi-str + "no-native-reassign": 2, // http://eslint.org/docs/rules/no-native-reassign + "no-new": 2, // http://eslint.org/docs/rules/no-new + "no-new-func": 2, // http://eslint.org/docs/rules/no-new-func + "no-new-wrappers": 2, // http://eslint.org/docs/rules/no-new-wrappers + "no-octal": 2, // http://eslint.org/docs/rules/no-octal + "no-octal-escape": 2, // http://eslint.org/docs/rules/no-octal-escape + "no-param-reassign": 2, // http://eslint.org/docs/rules/no-param-reassign + "no-proto": 2, // http://eslint.org/docs/rules/no-proto + "no-redeclare": 2, // http://eslint.org/docs/rules/no-redeclare + "no-return-assign": 2, // http://eslint.org/docs/rules/no-return-assign + "no-self-compare": 2, // http://eslint.org/docs/rules/no-self-compare + "no-sequences": 2, // http://eslint.org/docs/rules/no-sequences + "no-throw-literal": 2, // http://eslint.org/docs/rules/no-throw-literal + "no-with": 2, // http://eslint.org/docs/rules/no-with + "radix": 2, // http://eslint.org/docs/rules/radix + "vars-on-top": 2, // http://eslint.org/docs/rules/vars-on-top + "wrap-iife": [2, "any"], // http://eslint.org/docs/rules/wrap-iife + "yoda": 2, // http://eslint.org/docs/rules/yoda + +/** + * Style + */ + "indent": [2, 2], // http://eslint.org/docs/rules/indent + "brace-style": [2, // http://eslint.org/docs/rules/brace-style + "1tbs", { + "allowSingleLine": true + }], + "quotes": [ + 2, "single", "avoid-escape" // http://eslint.org/docs/rules/quotes + ], + "camelcase": [2, { // http://eslint.org/docs/rules/camelcase + "properties": "never" + }], + "comma-spacing": [2, { // http://eslint.org/docs/rules/comma-spacing + "before": false, + "after": true + }], + "comma-style": [2, "last"], // http://eslint.org/docs/rules/comma-style + "eol-last": 2, // http://eslint.org/docs/rules/eol-last + "func-names": 1, // http://eslint.org/docs/rules/func-names + "key-spacing": [2, { // http://eslint.org/docs/rules/key-spacing + "beforeColon": false, + "afterColon": true + }], + "new-cap": [2, { // http://eslint.org/docs/rules/new-cap + "newIsCap": true + }], + "no-multiple-empty-lines": [2, { // http://eslint.org/docs/rules/no-multiple-empty-lines + "max": 2 + }], + "no-new-object": 2, // http://eslint.org/docs/rules/no-new-object + "no-spaced-func": 2, // http://eslint.org/docs/rules/no-spaced-func + "no-trailing-spaces": 2, // http://eslint.org/docs/rules/no-trailing-spaces + "no-wrap-func": 2, // http://eslint.org/docs/rules/no-wrap-func + "no-underscore-dangle": 0, // http://eslint.org/docs/rules/no-underscore-dangle + "one-var": [2, "never"], // http://eslint.org/docs/rules/one-var + "padded-blocks": [2, "never"], // http://eslint.org/docs/rules/padded-blocks + "semi": [2, "always"], // http://eslint.org/docs/rules/semi + "semi-spacing": [2, { // http://eslint.org/docs/rules/semi-spacing + "before": false, + "after": true + }], + "space-after-keywords": 2, // http://eslint.org/docs/rules/space-after-keywords + "space-before-blocks": 2, // http://eslint.org/docs/rules/space-before-blocks + "space-before-function-paren": [2, "never"], // http://eslint.org/docs/rules/space-before-function-paren + "space-infix-ops": 2, // http://eslint.org/docs/rules/space-infix-ops + "space-return-throw-case": 2, // http://eslint.org/docs/rules/space-return-throw-case + "spaced-line-comment": 2, // http://eslint.org/docs/rules/spaced-line-comment + } +} diff --git a/extensions/mentions/bootstrap.php b/extensions/mentions/bootstrap.php index 3d7a86c2c..3e97f8a9e 100644 --- a/extensions/mentions/bootstrap.php +++ b/extensions/mentions/bootstrap.php @@ -1,9 +1,5 @@ app->register('Flarum\Mentions\MentionsServiceProvider'); +return 'Flarum\Mentions\Extension'; diff --git a/extensions/mentions/flarum.json b/extensions/mentions/flarum.json index f01e98753..8f8d88272 100644 --- a/extensions/mentions/flarum.json +++ b/extensions/mentions/flarum.json @@ -1,10 +1,8 @@ { - "name": "flarum-mentions", + "name": "mentions", "title": "Mentions", "description": "Mention and reply to specific posts and users.", - "tags": [ - "discussions" - ], + "keywords": ["discussions"], "version": "0.1.0", "author": { "name": "Toby Zerner", @@ -16,8 +14,8 @@ "php": ">=5.4.0", "flarum": ">0.1.0" }, - "links": { - "github": "https://github.com/flarum/mentions", + "support": { + "source": "https://github.com/flarum/mentions", "issues": "https://github.com/flarum/mentions/issues" } } diff --git a/extensions/mentions/js/bootstrap.js b/extensions/mentions/js/bootstrap.js deleted file mode 100644 index 369f5f42d..000000000 --- a/extensions/mentions/js/bootstrap.js +++ /dev/null @@ -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'] - }); - }); -}); diff --git a/extensions/mentions/js/Gulpfile.js b/extensions/mentions/js/forum/Gulpfile.js similarity index 77% rename from extensions/mentions/js/Gulpfile.js rename to extensions/mentions/js/forum/Gulpfile.js index 0dc805a75..baf078fb6 100644 --- a/extensions/mentions/js/Gulpfile.js +++ b/extensions/mentions/js/forum/Gulpfile.js @@ -1,7 +1,7 @@ var gulp = require('flarum-gulp'); gulp({ - modulePrefix: 'flarum-mentions', + modulePrefix: 'mentions', files: [ 'bower_components/textarea-caret-position/index.js' ] diff --git a/extensions/mentions/js/bower.json b/extensions/mentions/js/forum/bower.json similarity index 100% rename from extensions/mentions/js/bower.json rename to extensions/mentions/js/forum/bower.json diff --git a/extensions/mentions/js/package.json b/extensions/mentions/js/forum/package.json similarity index 100% rename from extensions/mentions/js/package.json rename to extensions/mentions/js/forum/package.json diff --git a/extensions/mentions/js/src/composer-autocomplete.js b/extensions/mentions/js/forum/src/addComposerAutocomplete.js similarity index 50% rename from extensions/mentions/js/src/composer-autocomplete.js rename to extensions/mentions/js/forum/src/addComposerAutocomplete.js index 49f502d13..c6c9dd2c4 100644 --- a/extensions/mentions/js/src/composer-autocomplete.js +++ b/extensions/mentions/js/forum/src/addComposerAutocomplete.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 = $('
'); - var dropdown = new AutocompleteDropdown({items: []}); - var typed; - var mentionStart; - var $textarea = this.$('textarea'); - var searched = []; - var searchTimeout; + const composer = this; + const $container = $('
'); + 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 ( + + ); }; - 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); } diff --git a/extensions/mentions/js/forum/src/addMentionedByList.js b/extensions/mentions/js/forum/src/addMentionedByList.js new file mode 100644 index 000000000..b8c43407e --- /dev/null +++ b/extensions/mentions/js/forum/src/addMentionedByList.js @@ -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 = $('