From 57bb8702de815f89d890b75b7692812b707ea7bd Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 14 May 2015 22:01:03 +0930 Subject: [PATCH] Initial commit --- extensions/mentions/.gitignore | 4 + extensions/mentions/LICENSE.txt | 21 +++ extensions/mentions/bootstrap.php | 9 ++ extensions/mentions/composer.json | 18 +++ extensions/mentions/extension.json | 15 +++ extensions/mentions/js/.gitignore | 4 + extensions/mentions/js/Gulpfile.js | 46 +++++++ extensions/mentions/js/bootstrap.js | 29 +++++ extensions/mentions/js/bower.json | 6 + extensions/mentions/js/package.json | 16 +++ .../src/components/autocomplete-dropdown.js | 88 +++++++++++++ .../components/notification-post-mentioned.js | 17 +++ .../components/notification-user-mentioned.js | 15 +++ .../mentions/js/src/composer-autocomplete.js | 120 ++++++++++++++++++ .../mentions/js/src/mentioned-by-list.js | 90 +++++++++++++ .../mentions/js/src/post-mention-previews.js | 81 ++++++++++++ .../mentions/js/src/post-reply-action.js | 32 +++++ extensions/mentions/less/mentions.less | 65 ++++++++++ ..._11_000000_create_mentions_posts_table.php | 31 +++++ ..._11_000000_create_mentions_users_table.php | 31 +++++ .../Handlers/PostMentionsMetadataUpdater.php | 46 +++++++ .../Handlers/UserMentionsMetadataUpdater.php | 46 +++++++ .../mentions/src/MentionsParserAbstract.php | 20 +++ .../mentions/src/MentionsServiceProvider.php | 69 ++++++++++ .../src/PostMentionedNotification.php | 47 +++++++ .../mentions/src/PostMentionsFormatter.php | 31 +++++ .../mentions/src/PostMentionsParser.php | 6 + .../src/UserMentionedNotification.php | 44 +++++++ .../mentions/src/UserMentionsFormatter.php | 20 +++ .../mentions/src/UserMentionsParser.php | 6 + 30 files changed, 1073 insertions(+) create mode 100644 extensions/mentions/.gitignore create mode 100644 extensions/mentions/LICENSE.txt create mode 100644 extensions/mentions/bootstrap.php create mode 100644 extensions/mentions/composer.json create mode 100644 extensions/mentions/extension.json create mode 100644 extensions/mentions/js/.gitignore create mode 100644 extensions/mentions/js/Gulpfile.js create mode 100644 extensions/mentions/js/bootstrap.js create mode 100644 extensions/mentions/js/bower.json create mode 100644 extensions/mentions/js/package.json create mode 100644 extensions/mentions/js/src/components/autocomplete-dropdown.js create mode 100644 extensions/mentions/js/src/components/notification-post-mentioned.js create mode 100644 extensions/mentions/js/src/components/notification-user-mentioned.js create mode 100644 extensions/mentions/js/src/composer-autocomplete.js create mode 100644 extensions/mentions/js/src/mentioned-by-list.js create mode 100644 extensions/mentions/js/src/post-mention-previews.js create mode 100644 extensions/mentions/js/src/post-reply-action.js create mode 100644 extensions/mentions/less/mentions.less create mode 100644 extensions/mentions/migrations/2015_05_11_000000_create_mentions_posts_table.php create mode 100644 extensions/mentions/migrations/2015_05_11_000000_create_mentions_users_table.php create mode 100755 extensions/mentions/src/Handlers/PostMentionsMetadataUpdater.php create mode 100755 extensions/mentions/src/Handlers/UserMentionsMetadataUpdater.php create mode 100644 extensions/mentions/src/MentionsParserAbstract.php create mode 100644 extensions/mentions/src/MentionsServiceProvider.php create mode 100644 extensions/mentions/src/PostMentionedNotification.php create mode 100644 extensions/mentions/src/PostMentionsFormatter.php create mode 100644 extensions/mentions/src/PostMentionsParser.php create mode 100644 extensions/mentions/src/UserMentionedNotification.php create mode 100644 extensions/mentions/src/UserMentionsFormatter.php create mode 100644 extensions/mentions/src/UserMentionsParser.php diff --git a/extensions/mentions/.gitignore b/extensions/mentions/.gitignore new file mode 100644 index 000000000..a4f3b125e --- /dev/null +++ b/extensions/mentions/.gitignore @@ -0,0 +1,4 @@ +/vendor +composer.phar +.DS_Store +Thumbs.db diff --git a/extensions/mentions/LICENSE.txt b/extensions/mentions/LICENSE.txt new file mode 100644 index 000000000..aa1e5fb86 --- /dev/null +++ b/extensions/mentions/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2015 Toby Zerner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/mentions/bootstrap.php b/extensions/mentions/bootstrap.php new file mode 100644 index 000000000..3d7a86c2c --- /dev/null +++ b/extensions/mentions/bootstrap.php @@ -0,0 +1,9 @@ +app->register('Flarum\Mentions\MentionsServiceProvider'); diff --git a/extensions/mentions/composer.json b/extensions/mentions/composer.json new file mode 100644 index 000000000..1e1933471 --- /dev/null +++ b/extensions/mentions/composer.json @@ -0,0 +1,18 @@ +{ + "name": "flarum/mentions", + "description": "", + "authors": [ + { + "name": "Toby Zerner", + "email": "toby@flarum.org" + } + ], + "require": { + "php": ">=5.4.0" + }, + "autoload": { + "psr-4": { + "Flarum\\Mentions\\": "src/" + } + } +} diff --git a/extensions/mentions/extension.json b/extensions/mentions/extension.json new file mode 100644 index 000000000..aacb8c8ce --- /dev/null +++ b/extensions/mentions/extension.json @@ -0,0 +1,15 @@ +{ + "name": "mentions", + "description": "", + "version": "0.1.0", + "author": { + "name": "Toby Zerner", + "email": "toby@flarum.org", + "website": "http://tobyzerner.com" + }, + "license": "MIT", + "require": { + "php": ">=5.4.0", + "flarum": ">1.0.0" + } +} diff --git a/extensions/mentions/js/.gitignore b/extensions/mentions/js/.gitignore new file mode 100644 index 000000000..bae304483 --- /dev/null +++ b/extensions/mentions/js/.gitignore @@ -0,0 +1,4 @@ +bower_components +node_modules +mithril.js +dist diff --git a/extensions/mentions/js/Gulpfile.js b/extensions/mentions/js/Gulpfile.js new file mode 100644 index 000000000..861a6f468 --- /dev/null +++ b/extensions/mentions/js/Gulpfile.js @@ -0,0 +1,46 @@ +var gulp = require('gulp'); +var livereload = require('gulp-livereload'); +var concat = require('gulp-concat'); +var argv = require('yargs').argv; +var uglify = require('gulp-uglify'); +var gulpif = require('gulp-if'); +var babel = require('gulp-babel'); +var cached = require('gulp-cached'); +var remember = require('gulp-remember'); +var merge = require('merge-stream'); +var streamqueue = require('streamqueue'); + +var staticFiles = [ + 'bootstrap.js', + 'bower_components/textarea-caret-position/index.js' +]; +var moduleFiles = [ + 'src/**/*.js' +]; +var modulePrefix = 'mentions'; + +gulp.task('default', function() { + return streamqueue({objectMode: true}, + gulp.src(moduleFiles) + .pipe(cached('scripts')) + .pipe(babel({ modules: 'amd', moduleIds: true, moduleRoot: modulePrefix })) + .pipe(remember('scripts')), + gulp.src(staticFiles) + .pipe(babel()) + ) + .pipe(concat('extension.js')) + .pipe(gulpif(argv.production, uglify())) + .pipe(gulp.dest('dist')) + .pipe(livereload()); +}); + +gulp.task('watch', ['default'], function () { + livereload.listen(); + var watcher = gulp.watch(moduleFiles.concat(staticFiles), ['default']); + watcher.on('change', function (event) { + if (event.type === 'deleted') { + delete cached.caches.scripts[event.path]; + remember.forget('scripts', event.path); + } + }); +}); diff --git a/extensions/mentions/js/bootstrap.js b/extensions/mentions/js/bootstrap.js new file mode 100644 index 000000000..4ecdcb061 --- /dev/null +++ b/extensions/mentions/js/bootstrap.js @@ -0,0 +1,29 @@ +import app from 'flarum/app'; + +import postMentionPreviews from 'mentions/post-mention-previews'; +import mentionedByList from 'mentions/mentioned-by-list'; +import postReplyAction from 'mentions/post-reply-action'; +import composerAutocomplete from 'mentions/composer-autocomplete'; +import NotificationPostMentioned from 'mentions/components/notification-post-mentioned'; +import NotificationUserMentioned from 'mentions/components/notification-user-mentioned'; + +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. + 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'] = NotificationPostMentioned; + app.notificationComponentRegistry['userMentioned'] = NotificationUserMentioned; +}); diff --git a/extensions/mentions/js/bower.json b/extensions/mentions/js/bower.json new file mode 100644 index 000000000..ce9c2a84b --- /dev/null +++ b/extensions/mentions/js/bower.json @@ -0,0 +1,6 @@ +{ + "name": "flarum-mentions", + "dev-dependencies": { + "textarea-caret-position": "~3.0.0" + } +} diff --git a/extensions/mentions/js/package.json b/extensions/mentions/js/package.json new file mode 100644 index 000000000..6d06deb71 --- /dev/null +++ b/extensions/mentions/js/package.json @@ -0,0 +1,16 @@ +{ + "name": "flarum-replies", + "devDependencies": { + "gulp": "^3.8.11", + "gulp-babel": "^5.1.0", + "gulp-cached": "^1.0.4", + "gulp-concat": "^2.5.2", + "gulp-if": "^1.2.5", + "gulp-livereload": "^3.8.0", + "gulp-remember": "^0.3.0", + "gulp-uglify": "^1.2.0", + "merge-stream": "^0.1.7", + "yargs": "^3.7.2", + "streamqueue": "^0.1.3" + } +} diff --git a/extensions/mentions/js/src/components/autocomplete-dropdown.js b/extensions/mentions/js/src/components/autocomplete-dropdown.js new file mode 100644 index 000000000..fc8f1d66b --- /dev/null +++ b/extensions/mentions/js/src/components/autocomplete-dropdown.js @@ -0,0 +1,88 @@ +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); + } + + view() { + return m('ul.dropdown-menu.mentions-dropdown', {config: this.element}, 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: // Down + this.setIndex(this.index() + 1, true); + e.preventDefault(); + break; + + case 38: // Up + this.setIndex(this.index() - 1, true); + 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) { + 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); + } + } + } +} diff --git a/extensions/mentions/js/src/components/notification-post-mentioned.js b/extensions/mentions/js/src/components/notification-post-mentioned.js new file mode 100644 index 000000000..44b7262e0 --- /dev/null +++ b/extensions/mentions/js/src/components/notification-post-mentioned.js @@ -0,0 +1,17 @@ +import Notification from 'flarum/components/notification'; +import username from 'flarum/helpers/username'; + +export default class NotificationPostMentioned 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'] + }); + } +} diff --git a/extensions/mentions/js/src/components/notification-user-mentioned.js b/extensions/mentions/js/src/components/notification-user-mentioned.js new file mode 100644 index 000000000..29e92d656 --- /dev/null +++ b/extensions/mentions/js/src/components/notification-user-mentioned.js @@ -0,0 +1,15 @@ +import Notification from 'flarum/components/notification'; +import username from 'flarum/helpers/username'; + +export default class NotificationUserMentioned 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'] + }); + } +} diff --git a/extensions/mentions/js/src/composer-autocomplete.js b/extensions/mentions/js/src/composer-autocomplete.js new file mode 100644 index 000000000..eac394777 --- /dev/null +++ b/extensions/mentions/js/src/composer-autocomplete.js @@ -0,0 +1,120 @@ +import { extend } from 'flarum/extension-utils'; +import ComposerBody from 'flarum/components/composer-body'; +import ComposerReply from 'flarum/components/composer-reply'; +import ComposerEdit from 'flarum/components/composer-edit'; +import avatar from 'flarum/helpers/avatar'; +import username from 'flarum/helpers/username'; + +import AutocompleteDropdown from 'mentions/components/autocomplete-dropdown'; + +export default function() { + extend(ComposerBody.prototype, 'onload', function(original, element, isInitialized, context) { + if (isInitialized) return; + + var composer = this; + var $container = $('
'); + var dropdown = new AutocompleteDropdown({items: []}); + + this.$('textarea') + .after($container) + .on('keydown', dropdown.navigate.bind(dropdown)) + .on('input', function() { + 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; + var mentionStart; + 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(); + + if (mentionStart) { + var typed = value.substring(mentionStart, cursor).toLowerCase(); + var suggestions = []; + + var applySuggestion = function(replacement) { + replacement += ' '; + + var content = composer.content(); + composer.editor.setContent(content.substring(0, mentionStart - 1)+replacement+content.substr(cursor)); + + var index = mentionStart + replacement.length; + composer.editor.setSelectionRange(index, index); + + dropdown.hide(); + }; + + var makeSuggestion = function(user, replacement, index, content) { + return m('a[href=javascript:;].post-preview', { + onclick: () => applySuggestion(replacement), + onmouseover: () => dropdown.setIndex(index) + }, m('div.post-preview-content', [ + avatar(user), + username(user), ' ', + content + ])); + }; + + // 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(post => post && post.contentType() === 'comment' && (!composerPost || post.number() < composerPost.number())) + .sort((a, b) => b.time() - a.time()) + .filter(post => { + var user = post.user(); + return user && user.username().toLowerCase().substr(0, typed.length) === typed; + }) + .splice(0, 5) + .forEach((post, i) => { + var user = post.user(); + suggestions.push( + makeSuggestion(user, '@'+user.username()+'#'+post.number(), i, [ + 'Reply to #', post.number(), ' — ', + post.excerpt() + ]) + ); + }); + } + + // If the user has started to type a username, then suggest users + // matching that username. + if (typed) { + app.store.all('users').forEach((user, i) => { + if (user.username().toLowerCase().substr(0, typed.length) !== typed) return; + + suggestions.push( + makeSuggestion(user, '@'+user.username(), i, '@mention') + ); + }); + } + + if (suggestions.length) { + dropdown.props.items = suggestions; + m.render($container[0], dropdown.view()); + + var coordinates = getCaretCoordinates(this, mentionStart); + dropdown.show(coordinates.left, coordinates.top + 15); + + dropdown.setIndex(0); + dropdown.$().scrollTop(0); + } + } + }); + }); +} diff --git a/extensions/mentions/js/src/mentioned-by-list.js b/extensions/mentions/js/src/mentioned-by-list.js new file mode 100644 index 000000000..12a9ac5f7 --- /dev/null +++ b/extensions/mentions/js/src/mentioned-by-list.js @@ -0,0 +1,90 @@ +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 PostComment from 'flarum/components/post-comment'; +import PostPreview from 'flarum/components/post-preview'; +import punctuate from 'flarum/helpers/punctuate'; + +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(PostComment.prototype, 'footerItems', function(items) { + var replies = this.props.post.mentionedBy(); + if (replies && replies.length) { + + 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 = $('