diff --git a/Gemfile b/Gemfile index 3aed5372411..76f6bf40345 100644 --- a/Gemfile +++ b/Gemfile @@ -187,7 +187,10 @@ gem 'lru_redux' # IMPORTANT: mini profiler monkey patches, so it better be required last # If you want to amend mini profiler to do the monkey patches in the railstie # we are open to it. by deferring require to the initializer we can configure disourse installs without it -gem 'rack-mini-profiler', '0.1.29', require: false # require: false #, git: 'git://github.com/SamSaffron/MiniProfiler' + +# gem 'rack-mini-profiler', '0.1.30', require: false +gem 'flamegraph', require: false +gem 'rack-mini-profiler', require: false # used for caching, optional # redis-rack-cache is missing a sane expiry policy, it hogs redis @@ -196,6 +199,7 @@ gem 'redis-rack-cache', git: 'https://github.com/SamSaffron/redis-rack-cache.git gem 'rack-cache', require: false gem 'rack-cors', require: false gem 'unicorn', require: false +gem 'puma', require: false # perftools only works on 1.9 atm group :profile do diff --git a/Gemfile.lock b/Gemfile.lock index a01605169e8..79a1ee0bbd0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -180,9 +180,14 @@ GEM fast_blank (0.0.1) rake rake-compiler + fast_stack (0.0.5) + rake + rake-compiler fast_xs (0.8.0) fastimage (1.3.0) ffi (1.8.1) + flamegraph (0.0.2) + fast_stack fog (1.14.0) builder excon (~> 0.25.0) @@ -219,7 +224,7 @@ GEM librarian (0.1.0) highline thor (~> 0.15) - libv8 (3.11.8.17) + libv8 (3.16.14.3) listen (0.7.3) lru_redux (0.0.6) mail (2.4.4) @@ -287,6 +292,8 @@ GEM pry (~> 0.9.10) pry-rails (0.2.2) pry (>= 0.9.10) + puma (2.5.1) + rack (>= 1.1, < 2.0) qunit-rails (0.0.3) railties (>= 3.2.3) rack (1.4.5) @@ -294,7 +301,7 @@ GEM rack (>= 0.4) rack-cors (0.2.7) rack - rack-mini-profiler (0.1.29) + rack-mini-profiler (0.1.31) rack (>= 1.1.3) rack-openid (1.3.1) rack (>= 1.1.0) @@ -423,8 +430,8 @@ GEM activemodel (~> 3.0) railties (~> 3.0) temple (0.6.4) - therubyracer (0.11.4) - libv8 (~> 3.11.8.12) + therubyracer (0.12.0) + libv8 (~> 3.16.14.0) ref thin (1.5.1) daemons (>= 1.0.9) @@ -472,6 +479,7 @@ DEPENDENCIES fast_xor! fast_xs fastimage + flamegraph fog handlebars-source (= 1.0.12) highline @@ -501,10 +509,11 @@ DEPENDENCIES pg pry-nav pry-rails + puma qunit-rails rack-cache rack-cors - rack-mini-profiler (= 0.1.29) + rack-mini-profiler rails (= 3.2.12) rails_multisite! rake diff --git a/Gemfile_rails4.lock b/Gemfile_rails4.lock index ebb7d5eaa1e..28d1aa381d5 100644 --- a/Gemfile_rails4.lock +++ b/Gemfile_rails4.lock @@ -33,7 +33,7 @@ GIT GIT remote: git://github.com/rails/rails.git - revision: e36692a7466011ab51393ac8ca6dfffcb9d79ec0 + revision: 025b63db308fbbf942a3bc2673d4aadab968c524 branch: 4-0-stable specs: actionmailer (4.0.0) @@ -216,9 +216,14 @@ GEM fast_blank (0.0.1) rake rake-compiler + fast_stack (0.0.5) + rake + rake-compiler fast_xs (0.8.0) fastimage (1.5.0) ffi (1.9.0) + flamegraph (0.0.2) + fast_stack fog (1.14.0) builder excon (~> 0.25.0) @@ -256,7 +261,7 @@ GEM librarian (0.1.0) highline thor (~> 0.15) - libv8 (3.11.8.17) + libv8 (3.16.14.3) listen (1.2.2) rb-fsevent (>= 0.9.3) rb-inotify (>= 0.9) @@ -267,7 +272,7 @@ GEM treetop (~> 1.4.8) metaclass (0.0.1) method_source (0.8.1) - mime-types (1.24) + mime-types (1.25) mini_portile (0.5.1) minitest (4.7.5) mocha (0.14.0) @@ -327,6 +332,8 @@ GEM pry (~> 0.9.10) pry-rails (0.3.1) pry (>= 0.9.10) + puma (2.5.1) + rack (>= 1.1, < 2.0) qunit-rails (0.0.3) railties (>= 3.2.3) rack (1.5.2) @@ -334,7 +341,7 @@ GEM rack (>= 0.4) rack-cors (0.2.8) rack - rack-mini-profiler (0.1.29) + rack-mini-profiler (0.1.31) rack (>= 1.1.3) rack-openid (1.3.1) rack (>= 1.1.0) @@ -432,8 +439,8 @@ GEM activesupport (>= 3.0) sprockets (~> 2.8) temple (0.6.5) - therubyracer (0.11.4) - libv8 (~> 3.11.8.12) + therubyracer (0.12.0) + libv8 (~> 3.16.14.0) ref thin (1.5.1) daemons (>= 1.0.9) @@ -482,6 +489,7 @@ DEPENDENCIES fast_xor! fast_xs fastimage + flamegraph fog handlebars-source (= 1.0.12) highline @@ -511,10 +519,11 @@ DEPENDENCIES pg pry-nav pry-rails + puma qunit-rails rack-cache rack-cors - rack-mini-profiler (= 0.1.29) + rack-mini-profiler rails! rails-observers rails_multisite! diff --git a/app/assets/javascripts/discourse/components/autocomplete.js b/app/assets/javascripts/discourse/components/autocomplete.js index 6e282f36a19..718c8841b7c 100644 --- a/app/assets/javascripts/discourse/components/autocomplete.js +++ b/app/assets/javascripts/discourse/components/autocomplete.js @@ -3,6 +3,49 @@ @module $.fn.autocomplete **/ + +var shiftMap = []; +shiftMap[192] = "~"; +shiftMap[49] = "!"; +shiftMap[50] = "@"; +shiftMap[51] = "#"; +shiftMap[52] = "$"; +shiftMap[53] = "%"; +shiftMap[54] = "^"; +shiftMap[55] = "&"; +shiftMap[56] = "*"; +shiftMap[57] = "("; +shiftMap[48] = ")"; +shiftMap[109] = "_"; +shiftMap[107] = "+"; +shiftMap[219] = "{"; +shiftMap[221] = "}"; +shiftMap[220] = "|"; +shiftMap[59] = ":"; +shiftMap[222] = "\""; +shiftMap[188] = "<"; +shiftMap[190] = ">"; +shiftMap[191] = "?"; +shiftMap[32] = " "; + +function mapKeyPressToActualCharacter(isShiftKey, characterCode) { + if ( characterCode === 27 || characterCode === 8 || characterCode === 9 || characterCode === 20 || characterCode === 16 || characterCode === 17 || characterCode === 91 || characterCode === 13 || characterCode === 92 || characterCode === 18 ) { return false; } + + if (isShiftKey) { + if ( characterCode >= 65 && characterCode <= 90 ) { + return String.fromCharCode(characterCode); + } else { + return shiftMap[characterCode]; + } + } else { + if ( characterCode >= 65 && characterCode <= 90 ) { + return String.fromCharCode(characterCode).toLowerCase(); + } else { + return String.fromCharCode(characterCode); + } + } +} + $.fn.autocomplete = function(options) { var autocompletePlugin = this; @@ -338,11 +381,15 @@ $.fn.autocomplete = function(options) { } term = me.val().substring(completeStart + (options.key ? 1 : 0), caretPosition); if (e.which >= 48 && e.which <= 90) { - term += String.fromCharCode(e.which); + term += mapKeyPressToActualCharacter(e.shiftKey, e.which); } else if (e.which === 187) { term += "+"; } else if (e.which === 189) { term += (e.shiftKey) ? "_" : "-"; + } else if (e.which === 220) { + term += (e.shiftKey) ? "|" : "]"; + } else if (e.which === 222) { + term += (e.shiftKey) ? "\"" : "'"; } else { if (e.which !== 8) { term += ","; diff --git a/app/assets/javascripts/discourse/components/formatter.js b/app/assets/javascripts/discourse/components/formatter.js index 8a21385a46e..85f0de23ad1 100644 --- a/app/assets/javascripts/discourse/components/formatter.js +++ b/app/assets/javascripts/discourse/components/formatter.js @@ -13,13 +13,29 @@ Discourse.Formatter = (function(){ var firstPart = string.substr(0, maxLength); - var betterSplit = firstPart.substr(1).search(/[^a-z]/); - if (betterSplit >= 0) { - var offset = 1; - if(string[betterSplit+1] === "_") { - offset = 2; + // work backward to split stuff like ABPoop to AB Poop + var i; + for(i=firstPart.length-1;i>0;i--){ + if(firstPart[i].match(/[A-Z]/)){ + break; } - return string.substr(0, betterSplit + offset) + " " + string.substring(betterSplit + offset); + } + + // work forwards to split stuff like ab111 to ab 111 + if(i===0) { + for(i=1;i 0 && i < firstPart.length) { + var offset = 0; + if(string[i] === "_") { + offset = 1; + } + return string.substr(0, i + offset) + " " + string.substring(i + offset); } else { return firstPart + " " + string.substr(maxLength); } diff --git a/app/assets/javascripts/discourse/components/quote.js b/app/assets/javascripts/discourse/components/quote.js index 496a2dc99f0..54692464ca5 100644 --- a/app/assets/javascripts/discourse/components/quote.js +++ b/app/assets/javascripts/discourse/components/quote.js @@ -23,6 +23,10 @@ Discourse.Quote = { sansQuotes = contents.replace(this.REGEXP, '').trim(); if (sansQuotes.length === 0) return ""; + // Escape the content of the quote + sansQuotes = sansQuotes.replace(//g, ">"); + result = "[quote=\"" + post.get('username') + ", post:" + post.get('post_number') + ", topic:" + post.get('topic_id'); /* Strip the HTML from cooked */ diff --git a/app/assets/javascripts/discourse/controllers/avatar_selector_controller.js b/app/assets/javascripts/discourse/controllers/avatar_selector_controller.js index 861853ca41c..91ce9648bc5 100644 --- a/app/assets/javascripts/discourse/controllers/avatar_selector_controller.js +++ b/app/assets/javascripts/discourse/controllers/avatar_selector_controller.js @@ -8,7 +8,15 @@ @module Discourse **/ Discourse.AvatarSelectorController = Discourse.Controller.extend(Discourse.ModalFunctionality, { - toggleUseUploadedAvatar: function(toggle) { - this.set("use_uploaded_avatar", toggle); - } + useUploadedAvatar: function() { + this.set("use_uploaded_avatar", true); + }, + + useGravatar: function() { + this.set("use_uploaded_avatar", false); + }, + + avatarTemplate: function() { + return this.get("use_uploaded_avatar") ? this.get("uploaded_avatar_template") : this.get("gravatar_template"); + }.property("use_uploaded_avatar", "uploaded_avatar_template", "gravatar_template") }); diff --git a/app/assets/javascripts/discourse/controllers/edit_topic_auto_close_controller.js b/app/assets/javascripts/discourse/controllers/edit_topic_auto_close_controller.js index da86cfcdf93..6301c2def93 100644 --- a/app/assets/javascripts/discourse/controllers/edit_topic_auto_close_controller.js +++ b/app/assets/javascripts/discourse/controllers/edit_topic_auto_close_controller.js @@ -13,7 +13,7 @@ Discourse.EditTopicAutoCloseController = Discourse.ObjectController.extend(Disco if( this.get('details.auto_close_at') ) { var closeTime = new Date( this.get('details.auto_close_at') ); if (closeTime > new Date()) { - this.set('auto_close_days', closeTime.daysSince()); + this.set('auto_close_days', Math.round(moment(closeTime).diff(new Date(), 'days', true))); } } else { this.set('details.auto_close_days', ''); diff --git a/app/assets/javascripts/discourse/controllers/flag_controller.js b/app/assets/javascripts/discourse/controllers/flag_controller.js index 1284e847918..a573161bba3 100644 --- a/app/assets/javascripts/discourse/controllers/flag_controller.js +++ b/app/assets/javascripts/discourse/controllers/flag_controller.js @@ -58,9 +58,11 @@ Discourse.FlagController = Discourse.ObjectController.extend(Discourse.ModalFunc if (opts) params = $.extend(params, opts); + $('#discourse-modal').modal('hide'); postAction.act(params).then(function() { flagController.send('closeModal'); }, function(errors) { + $('#discourse-modal').modal('show'); flagController.displayErrors(errors); }); }, diff --git a/app/assets/javascripts/discourse/controllers/login_controller.js b/app/assets/javascripts/discourse/controllers/login_controller.js index 3cdbf674ffd..58664f6ff6a 100644 --- a/app/assets/javascripts/discourse/controllers/login_controller.js +++ b/app/assets/javascripts/discourse/controllers/login_controller.js @@ -95,6 +95,11 @@ Discourse.LoginController = Discourse.Controller.extend(Discourse.ModalFunctiona }, authenticationComplete: function(options) { + if (options.requires_invite) { + this.flash(I18n.t('login.requires_invite'), 'success'); + this.set('authenticate', null); + return; + } if (options.awaiting_approval) { this.flash(I18n.t('login.awaiting_approval'), 'success'); this.set('authenticate', null); diff --git a/app/assets/javascripts/discourse/controllers/merge_topic_controller.js b/app/assets/javascripts/discourse/controllers/merge_topic_controller.js index 9d79f00c3ff..672654877ca 100644 --- a/app/assets/javascripts/discourse/controllers/merge_topic_controller.js +++ b/app/assets/javascripts/discourse/controllers/merge_topic_controller.js @@ -12,6 +12,7 @@ Discourse.MergeTopicController = Discourse.ObjectController.extend(Discourse.Sel topicController: Em.computed.alias('controllers.topic'), selectedPosts: Em.computed.alias('topicController.selectedPosts'), + selectedReplies: Em.computed.alias('topicController.selectedReplies'), allPostsSelected: Em.computed.alias('topicController.allPostsSelected'), buttonDisabled: function() { @@ -31,10 +32,13 @@ Discourse.MergeTopicController = Discourse.ObjectController.extend(Discourse.Sel if (this.get('allPostsSelected')) { promise = Discourse.Topic.mergeTopic(this.get('id'), this.get('selectedTopicId')); } else { - var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); }); + var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); }), + replyPostIds = this.get('selectedReplies').map(function(p) { return p.get('id'); }); + promise = Discourse.Topic.movePosts(this.get('id'), { destination_topic_id: this.get('selectedTopicId'), - post_ids: postIds + post_ids: postIds, + reply_post_ids: replyPostIds }); } diff --git a/app/assets/javascripts/discourse/controllers/split_topic_controller.js b/app/assets/javascripts/discourse/controllers/split_topic_controller.js index b07b007b095..7d2dd5993dd 100644 --- a/app/assets/javascripts/discourse/controllers/split_topic_controller.js +++ b/app/assets/javascripts/discourse/controllers/split_topic_controller.js @@ -12,6 +12,7 @@ Discourse.SplitTopicController = Discourse.ObjectController.extend(Discourse.Sel topicController: Em.computed.alias('controllers.topic'), selectedPosts: Em.computed.alias('topicController.selectedPosts'), + selectedReplies: Em.computed.alias('topicController.selectedReplies'), buttonDisabled: function() { if (this.get('saving')) return true; @@ -30,21 +31,23 @@ Discourse.SplitTopicController = Discourse.ObjectController.extend(Discourse.Sel movePostsToNewTopic: function() { this.set('saving', true); - var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); }); - var splitTopicController = this; + var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); }), + replyPostIds = this.get('selectedReplies').map(function(p) { return p.get('id'); }), + self = this; Discourse.Topic.movePosts(this.get('id'), { title: this.get('topicName'), - post_ids: postIds + post_ids: postIds, + reply_post_ids: replyPostIds }).then(function(result) { // Posts moved - splitTopicController.send('closeModal'); - splitTopicController.get('topicController').toggleMultiSelect(); + self.send('closeModal'); + self.get('topicController').toggleMultiSelect(); Em.run.next(function() { Discourse.URL.routeTo(result.url); }); }, function() { // Error moving posts - splitTopicController.flash(I18n.t('topic.split_topic.error')); - splitTopicController.set('saving', false); + self.flash(I18n.t('topic.split_topic.error')); + self.set('saving', false); }); return false; } diff --git a/app/assets/javascripts/discourse/controllers/topic_controller.js b/app/assets/javascripts/discourse/controllers/topic_controller.js index 6dc6c23c60b..549682818da 100644 --- a/app/assets/javascripts/discourse/controllers/topic_controller.js +++ b/app/assets/javascripts/discourse/controllers/topic_controller.js @@ -11,8 +11,15 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected summaryCollapsed: true, needs: ['header', 'modal', 'composer', 'quoteButton'], allPostsSelected: false, - selectedPosts: new Em.Set(), editingTopic: false, + selectedPosts: null, + selectedReplies: null, + + init: function() { + this._super(); + this.set('selectedPosts', new Em.Set()); + this.set('selectedReplies', new Em.Set()); + }, jumpTopDisabled: function() { return (this.get('progressPosition') === 1); @@ -82,18 +89,48 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected return false; }.property('postStream.loaded', 'currentPost', 'postStream.filteredPostsCount'), - selectPost: function(post) { + deselectPost: function(post) { + this.get('selectedPosts').removeObject(post); + + var selectedReplies = this.get('selectedReplies'); + selectedReplies.removeObject(post); + + var selectedReply = selectedReplies.findProperty('post_number', post.get('reply_to_post_number')); + if (selectedReply) { selectedReplies.removeObject(selectedReply); } + + this.set('allPostsSelected', false); + }, + + postSelected: function(post) { + if (this.get('allPostsSelected')) { return true; } + if (this.get('selectedPosts').contains(post)) { return true; } + if (this.get('selectedReplies').findProperty('post_number', post.get('reply_to_post_number'))) { return true; } + + return false; + }, + + toggledSelectedPost: function(post) { var selectedPosts = this.get('selectedPosts'); - if (selectedPosts.contains(post)) { - selectedPosts.removeObject(post); - this.set('allPostsSelected', false); + if (this.postSelected(post)) { + this.deselectPost(post); + return false; } else { selectedPosts.addObject(post); // If the user manually selects all posts, all posts are selected if (selectedPosts.length === this.get('posts_count')) { - this.set('allPostsSelected'); + this.set('allPostsSelected', true); } + return true; + } + }, + + toggledSelectedPostReplies: function(post) { + var selectedReplies = this.get('selectedReplies'); + if (this.toggledSelectedPost(post)) { + selectedReplies.addObject(post); + } else { + selectedReplies.removeObject(post); } }, @@ -108,6 +145,7 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected deselectAll: function() { this.get('selectedPosts').clear(); + this.get('selectedReplies').clear(); this.set('allPostsSelected', false); }, @@ -177,19 +215,28 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected }, deleteSelected: function() { - var topicController = this; + var self = this; bootbox.confirm(I18n.t("post.delete.confirm", { count: this.get('selectedPostsCount')}), function(result) { if (result) { // If all posts are selected, it's the same thing as deleting the topic - if (topicController.get('allPostsSelected')) { - return topicController.deleteTopic(); + if (self.get('allPostsSelected')) { + return self.deleteTopic(); } - var selectedPosts = topicController.get('selectedPosts'); - Discourse.Post.deleteMany(selectedPosts); - topicController.get('model.postStream').removePosts(selectedPosts); - topicController.toggleMultiSelect(); + var selectedPosts = self.get('selectedPosts'), + selectedReplies = self.get('selectedReplies'), + postStream = self.get('postStream'), + toRemove = new Ember.Set(); + + + Discourse.Post.deleteMany(selectedPosts, selectedReplies); + postStream.get('posts').forEach(function (p) { + if (self.postSelected(p)) { toRemove.addObject(p); } + }); + + postStream.removePosts(toRemove); + self.toggleMultiSelect(); } }); }, @@ -410,7 +457,33 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected }, deletePost: function(post) { - post.destroy(Discourse.User.current()); + var user = Discourse.User.current(), + replyCount = post.get('reply_count'), + self = this; + + // If the user is staff and the post has replies, ask if they want to delete replies too. + if (user.get('staff') && replyCount > 0) { + bootbox.confirm(I18n.t("post.controls.delete_replies.confirm", {count: replyCount}), + I18n.t("post.controls.delete_replies.no_value"), + I18n.t("post.controls.delete_replies.yes_value"), + function(result) { + + // If the user wants to delete replies, do that, otherwise delete the post as normal. + if (result) { + Discourse.Post.deleteMany([post], [post]); + self.get('postStream.posts').forEach(function (p) { + if (p === post || p.get('reply_to_post_number') === post.get('post_number')) { + p.setDeletedState(user); + } + }); + } else { + post.destroy(user); + } + + }); + } else { + post.destroy(user); + } }, removeAllowedUser: function(username) { diff --git a/app/assets/javascripts/discourse/controllers/user_activity_controller.js b/app/assets/javascripts/discourse/controllers/user_activity_controller.js index 038d786bc07..e765f09fee0 100644 --- a/app/assets/javascripts/discourse/controllers/user_activity_controller.js +++ b/app/assets/javascripts/discourse/controllers/user_activity_controller.js @@ -24,5 +24,6 @@ Discourse.UserActivityController = Discourse.ObjectController.extend({ }, privateMessagesActive: Em.computed.equal('pmView', 'index'), - privateMessagesSentActive: Em.computed.equal('pmView', 'sent') + privateMessagesMineActive: Em.computed.equal('pmView', 'mine'), + privateMessagesUnreadActive: Em.computed.equal('pmView', 'unread') }); diff --git a/app/assets/javascripts/discourse/dialects/autolink_dialect.js b/app/assets/javascripts/discourse/dialects/autolink_dialect.js index 4846abd6815..8917939d1b4 100644 --- a/app/assets/javascripts/discourse/dialects/autolink_dialect.js +++ b/app/assets/javascripts/discourse/dialects/autolink_dialect.js @@ -1,45 +1,19 @@ /** This addition handles auto linking of text. When included, it will parse out links and create a hrefs for them. - - @event register - @namespace Discourse.Dialect **/ -Discourse.Dialect.on("register", function(event) { +var urlReplacerArgs = { + matcher: /^((?:https?:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.])(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^`!()\[\]{};:'".,<>?«»“”‘’\s]))/gm, + spaceBoundary: true, - var dialect = event.dialect, - MD = event.MD; + emitter: function(matches) { + var url = matches[1], + displayUrl = url; - /** - Parses out links from HTML. + if (url.match(/^www/)) { url = "http://" + url; } + return ['a', {href: url}, displayUrl]; + } +}; - @method autoLink - @param {String} text the text match - @param {Array} match the match found - @param {Array} prev the previous jsonML - @return {Array} an array containing how many chars we've replaced and the jsonML content for it. - @namespace Discourse.Dialect - **/ - dialect.inline['http'] = dialect.inline['www'] = function autoLink(text, match, prev) { - - // We only care about links on boundaries - if (prev && (prev.length > 0)) { - var last = prev[prev.length - 1]; - if (typeof last === "string" && (!last.match(/\s$/))) { return; } - } - - var pattern = /(^|\s)((?:https?:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.])(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^`!()\[\]{};:'".,<>?«»“”‘’\s]))/gm, - m = pattern.exec(text); - - if (m) { - var url = m[2], - displayUrl = m[2]; - - if (url.match(/^www/)) { url = "http://" + url; } - return [m[0].length, ['a', {href: url}, displayUrl]]; - } - - }; - - -}); \ No newline at end of file +Discourse.Dialect.inlineRegexp(_.merge({start: 'http'}, urlReplacerArgs)); +Discourse.Dialect.inlineRegexp(_.merge({start: 'www'}, urlReplacerArgs)); diff --git a/app/assets/javascripts/discourse/dialects/bbcode_dialect.js b/app/assets/javascripts/discourse/dialects/bbcode_dialect.js index 8f2c9fb3071..9216deada47 100644 --- a/app/assets/javascripts/discourse/dialects/bbcode_dialect.js +++ b/app/assets/javascripts/discourse/dialects/bbcode_dialect.js @@ -1,205 +1,114 @@ /** - Regsiter all functionality for supporting BBCode in Discourse. + Create a simple BBCode tag handler - @event register - @namespace Discourse.Dialect + @method replaceBBCode + @param {tag} tag the tag we want to match + @param {function} emitter the function that creates JsonML for the tag **/ -Discourse.Dialect.on("register", function(event) { - - var dialect = event.dialect, - MD = event.MD; - - var createBBCode = function(tag, builder, hasArgs) { - return function(text, orig_match) { - var bbcodePattern = new RegExp("\\[" + tag + "=?([^\\[\\]]+)?\\]([\\s\\S]*?)\\[\\/" + tag + "\\]", "igm"); - var m = bbcodePattern.exec(text); - if (m && m[0]) { - return [m[0].length, builder(m, this)]; - } - }; - }; - - var bbcodes = {'b': ['span', {'class': 'bbcode-b'}], - 'i': ['span', {'class': 'bbcode-i'}], - 'u': ['span', {'class': 'bbcode-u'}], - 's': ['span', {'class': 'bbcode-s'}], - 'spoiler': ['span', {'class': 'spoiler'}], - 'li': ['li'], - 'ul': ['ul'], - 'ol': ['ol']}; - - Object.keys(bbcodes).forEach(function(tag) { - var element = bbcodes[tag]; - dialect.inline["[" + tag + "]"] = createBBCode(tag, function(m, self) { - return element.concat(self.processInline(m[2])); - }); +function replaceBBCode(tag, emitter) { + Discourse.Dialect.inlineBetween({ + start: "[" + tag + "]", + stop: "[/" + tag + "]", + emitter: emitter }); +} - dialect.inline["[img]"] = createBBCode('img', function(m) { - return ['img', {href: m[2]}]; - }); +/** + Creates a BBCode handler that accepts parameters. Passes them to the emitter. - dialect.inline["[email]"] = createBBCode('email', function(m) { - return ['a', {href: "mailto:" + m[2], 'data-bbcode': true}, m[2]]; - }); + @method replaceBBCodeParamsRaw + @param {tag} tag the tag we want to match + @param {function} emitter the function that creates JsonML for the tag +**/ +function replaceBBCodeParamsRaw(tag, emitter) { + Discourse.Dialect.inlineBetween({ + start: "[" + tag + "=", + stop: "[/" + tag + "]", + rawContents: true, + emitter: function(contents) { + var regexp = /^([^\]]+)\](.*)$/, + m = regexp.exec(contents); - dialect.inline["[url]"] = createBBCode('url', function(m) { - return ['a', {href: m[2], 'data-bbcode': true}, m[2]]; - }); - - dialect.inline["[url="] = createBBCode('url', function(m, self) { - return ['a', {href: m[1], 'data-bbcode': true}].concat(self.processInline(m[2])); - }); - - dialect.inline["[email="] = createBBCode('email', function(m, self) { - return ['a', {href: "mailto:" + m[1], 'data-bbcode': true}].concat(self.processInline(m[2])); - }); - - dialect.inline["[size="] = createBBCode('size', function(m, self) { - return ['span', {'class': "bbcode-size-" + m[1]}].concat(self.processInline(m[2])); - }); - - dialect.inline["[color="] = function(text, orig_match) { - var bbcodePattern = new RegExp("\\[color=?([^\\[\\]]+)?\\]([\\s\\S]*?)\\[\\/color\\]", "igm"), - m = bbcodePattern.exec(text); - - if (m && m[0]) { - if (!/^(\#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?)|(aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)$/.test(m[1])) { - return [m[0].length].concat(this.processInline(m[2])); - } - return [m[0].length, ['span', {style: "color: " + m[1]}].concat(this.processInline(m[2]))]; + if (m) { return emitter.call(this, m[1], m[2]); } } - }; + }); +} - /** - Support BBCode [code] blocks +/** + Creates a BBCode handler that accepts parameters. Passes them to the emitter. + Processes the inside recursively so it can be nested. - @method bbcodeCode - @param {Markdown.Block} block the block to examine - @param {Array} next the next blocks in the sequence - @return {Array} the JsonML containing the markup or undefined if nothing changed. - @namespace Discourse.Dialect - **/ - dialect.inline["[code]"] = function bbcodeCode(text, orig_match) { - var bbcodePattern = new RegExp("\\[code\\]([\\s\\S]*?)\\[\\/code\\]", "igm"), - m = bbcodePattern.exec(text); + @method replaceBBCodeParams + @param {tag} tag the tag we want to match + @param {function} emitter the function that creates JsonML for the tag +**/ +function replaceBBCodeParams(tag, emitter) { + replaceBBCodeParamsRaw(tag, function (param, contents) { + return emitter(param, this.processInline(contents)); + }); +} - if (m) { - var contents = m[1].trim().split("\n"); +replaceBBCode('b', function(contents) { return ['span', {'class': 'bbcode-b'}].concat(contents); }); +replaceBBCode('i', function(contents) { return ['span', {'class': 'bbcode-i'}].concat(contents); }); +replaceBBCode('u', function(contents) { return ['span', {'class': 'bbcode-u'}].concat(contents); }); +replaceBBCode('s', function(contents) { return ['span', {'class': 'bbcode-s'}].concat(contents); }); - var html = ['pre', "\n"]; - contents.forEach(function (n) { - html.push(n.trim()); - html.push(["br"]); - html.push("\n"); - }); +replaceBBCode('ul', function(contents) { return ['ul'].concat(contents); }); +replaceBBCode('ol', function(contents) { return ['ol'].concat(contents); }); +replaceBBCode('li', function(contents) { return ['li'].concat(contents); }); - return [m[0].length, html]; - } - }; +replaceBBCode('spoiler', function(contents) { return ['span', {'class': 'spoiler'}].concat(contents); }); - /** - Support BBCode [quote] blocks +Discourse.Dialect.inlineBetween({ + start: '[img]', + stop: '[/img]', + rawContents: true, + emitter: function(contents) { return ['img', {href: contents}]; } +}); - @method bbcodeQuote - @param {Markdown.Block} block the block to examine - @param {Array} next the next blocks in the sequence - @return {Array} the JsonML containing the markup or undefined if nothing changed. - @namespace Discourse.Dialect - **/ - dialect.block['quote'] = function bbcodeQuote(block, next) { - var m = new RegExp("\\[quote=?([^\\[\\]]+)?\\]([\\s\\S]*)", "igm").exec(block); - if (m) { - var paramsString = m[1].replace(/\"/g, ''), - params = {'class': 'quote'}, - paramsSplit = paramsString.split(/\, */), - username = paramsSplit[0], - opts = dialect.options, - startPos = block.indexOf(m[0]), - leading, - quoteContents = [], - result = []; - - if (startPos > 0) { - leading = block.slice(0, startPos); - - var para = ['p']; - this.processInline(leading).forEach(function (l) { - para.push(l); - }); - - result.push(para); - } - - paramsSplit.forEach(function(p,i) { - if (i > 0) { - var assignment = p.split(':'); - if (assignment[0] && assignment[1]) { - params['data-' + assignment[0]] = assignment[1].trim(); - } - } - }); - - var avatarImg; - if (opts.lookupAvatarByPostNumber) { - // client-side, we can retrieve the avatar from the post - var postNumber = parseInt(params['data-post'], 10); - avatarImg = opts.lookupAvatarByPostNumber(postNumber); - } else if (opts.lookupAvatar) { - // server-side, we need to lookup the avatar from the username - avatarImg = opts.lookupAvatar(username); - } - - if (m[2]) { next.unshift(MD.mk_block(m[2])); } - - while (next.length > 0) { - var b = next.shift(), - n = b.match(/([\s\S]*)\[\/quote\]([\s\S]*)/m); - - if (n) { - if (n[2]) { - next.unshift(MD.mk_block(n[2])); - } - quoteContents.push(n[1]); - break; - } else { - quoteContents.push(b); - } - } - - var contents = this.processInline(quoteContents.join(" \n \n")); - contents.unshift('blockquote'); - - - result.push(['p', ['aside', params, - ['div', {'class': 'title'}, - ['div', {'class': 'quote-controls'}], - avatarImg ? avatarImg : "", - I18n.t('user.said',{username: username}) - ], - contents - ]]); - return result; - } - }; +Discourse.Dialect.inlineBetween({ + start: '[email]', + stop: '[/email]', + rawContents: true, + emitter: function(contents) { return ['a', {href: "mailto:" + contents, 'data-bbcode': true}, contents]; } +}); +Discourse.Dialect.inlineBetween({ + start: '[url]', + stop: '[/url]', + rawContents: true, + emitter: function(contents) { return ['a', {href: contents, 'data-bbcode': true}, contents]; } }); -Discourse.Dialect.on("parseNode", function(event) { +replaceBBCodeParamsRaw("url", function(param, contents) { + return ['a', {href: param, 'data-bbcode': true}, contents]; +}); - var node = event.node, - path = event.path; +replaceBBCodeParamsRaw("email", function(param, contents) { + return ['a', {href: "mailto:" + param, 'data-bbcode': true}, contents]; +}); - // Make sure any quotes are followed by a
. The formatting looks weird otherwise. - if (node[0] === 'aside' && node[1] && node[1]['class'] === 'quote') { - var parent = path[path.length - 1], - location = parent.indexOf(node)+1, - trailing = parent.slice(location); +replaceBBCodeParams("size", function(param, contents) { + return ['span', {'class': "bbcode-size-" + param}].concat(contents); +}); - if (trailing.length) { - parent.splice(location, 0, ['br']); - } +replaceBBCodeParams("color", function(param, contents) { + // Only allow valid HTML colors. + if (/^(\#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?)|(aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)$/.test(param)) { + return ['span', {style: "color: " + param}].concat(contents); + } else { + return ['span'].concat(contents); } - }); + +// Handles `[code] ... [/code]` blocks +Discourse.Dialect.replaceBlock({ + start: /(\[code\])([\s\S]*)/igm, + stop: '[/code]', + + emitter: function(blockContents) { + return ['p', ['pre'].concat(blockContents)]; + } +}); + diff --git a/app/assets/javascripts/discourse/dialects/bold_italics_dialect.js b/app/assets/javascripts/discourse/dialects/bold_italics_dialect.js index 704b6a16465..7cea70c8485 100644 --- a/app/assets/javascripts/discourse/dialects/bold_italics_dialect.js +++ b/app/assets/javascripts/discourse/dialects/bold_italics_dialect.js @@ -1,32 +1,42 @@ /** - Markdown.js doesn't seem to do bold and italics at the same time if you surround code with - three asterisks. This adds that support. - - @event register - @namespace Discourse.Dialect + markdown-js doesn't ensure that em/strong codes are present on word boundaries. + So we create our own handlers here. **/ -Discourse.Dialect.on("register", function(event) { +// Support for simultaneous bold and italics +Discourse.Dialect.inlineBetween({ + between: '***', + wordBoundary: true, + emitter: function(contents) { return ['strong', ['em'].concat(contents)]; } +}); + +// Builds a common markdown replacer +var replaceMarkdown = function(match, tag) { + Discourse.Dialect.inlineBetween({ + between: match, + wordBoundary: true, + emitter: function(contents) { return [tag].concat(contents) } + }); +}; + +replaceMarkdown('**', 'strong'); +replaceMarkdown('__', 'strong'); +replaceMarkdown('*', 'em'); +replaceMarkdown('_', 'em'); + + +// There's a weird issue with the markdown parser where it won't process simple blockquotes +// when they are prefixed with spaces. This fixes it. +Discourse.Dialect.on("register", function(event) { var dialect = event.dialect, MD = event.MD; - /** - Handles simultaneous bold and italics - - @method parseMentions - @param {String} text the text match - @param {Array} match the match found - @param {Array} prev the previous jsonML - @return {Array} an array containing how many chars we've replaced and the jsonML content for it. - @namespace Discourse.Dialect - **/ - dialect.inline['***'] = function boldItalics(text, match, prev) { - var regExp = /^\*{3}([^\*]+)\*{3}/, - m = regExp.exec(text); - - if (m) { - return [m[0].length, ['strong', ['em'].concat(this.processInline(m[1]))]]; + dialect.block["fix_simple_quotes"] = function(block, next) { + var m = /^ +(\>[\s\S]*)/.exec(block); + if (m && m[1] && m[1].length) { + next.unshift(MD.mk_block(m[1])); + return []; } }; -}); +}); \ No newline at end of file diff --git a/app/assets/javascripts/discourse/dialects/dialect.js b/app/assets/javascripts/discourse/dialects/dialect.js index 6d2d50e535c..58bb55f7a17 100644 --- a/app/assets/javascripts/discourse/dialects/dialect.js +++ b/app/assets/javascripts/discourse/dialects/dialect.js @@ -3,91 +3,70 @@ Discourse uses the Markdown.js as its main parser. `Discourse.Dialect` is the framework for extending it with additional formatting. - To extend the dialect, you can register a handler, and you will receive an `event` object - with a handle to the markdown `Dialect` from Markdown.js that we are defining. Here's - a sample dialect that replaces all occurrences of "evil trout" with a link that says - "EVIL TROUT IS AWESOME": - - ```javascript - - Discourse.Dialect.on("register", function(event) { - var dialect = event.dialect; - - // To see how this works, review one of our samples or the Markdown.js code: - dialect.inline["evil trout"] = function(text) { - return ["evil trout".length, ['a', {href: "http://eviltrout.com"}, "EVIL TROUT IS AWESOME"] ]; - }; - - }); - ``` - - You can also manipulate the JsonML tree that is produced by the parser before it converted to HTML. - This is useful if the markup you want needs a certain structure of HTML elements. Rather than - writing regular expressions to match HTML, consider parsing the tree instead! We use this for - making sure a onebox is on one line, as an example. - - This example changes the content of any `` tags. - - The `event.path` attribute contains the current path to the node. - - ```javascript - Discourse.Dialect.on("parseNode", function(event) { - var node = event.node; - - if (node[0] === 'code') { - node[node.length-1] = "EVIL TROUT HACKED YOUR CODE"; - } - }); - ``` - **/ var parser = window.BetterMarkdown, MD = parser.Markdown, - - // Our dialect dialect = MD.dialects.Discourse = MD.subclassDialect( MD.dialects.Gruber ), + initialized = false; - initialized = false, +/** + Initialize our dialects for processing. - /** - Initialize our dialects for processing. + @method initializeDialects +**/ +function initializeDialects() { + Discourse.Dialect.trigger('register', {dialect: dialect, MD: MD}); + MD.buildBlockOrder(dialect.block); + MD.buildInlinePatterns(dialect.inline); + initialized = true; +} - @method initializeDialects - **/ - initializeDialects = function() { - Discourse.Dialect.trigger('register', {dialect: dialect, MD: MD}); - MD.buildBlockOrder(dialect.block); - MD.buildInlinePatterns(dialect.inline); - initialized = true; - }, +/** + Parse a JSON ML tree, using registered handlers to adjust it if necessary. - /** - Parse a JSON ML tree, using registered handlers to adjust it if necessary. + @method parseTree + @param {Array} tree the JsonML tree to parse + @param {Array} path the path of ancestors to the current node in the tree. Can be used for matching. + @param {Object} insideCounts counts what tags we're inside + @returns {Array} the parsed tree +**/ +function parseTree(tree, path, insideCounts) { + if (tree instanceof Array) { + Discourse.Dialect.trigger('parseNode', {node: tree, path: path, dialect: dialect, insideCounts: insideCounts || {}}); - @method parseTree - @param {Array} tree the JsonML tree to parse - @param {Array} path the path of ancestors to the current node in the tree. Can be used for matching. - @param {Object} insideCounts counts what tags we're inside - @returns {Array} the parsed tree - **/ - parseTree = function parseTree(tree, path, insideCounts) { - if (tree instanceof Array) { - Discourse.Dialect.trigger('parseNode', {node: tree, path: path, dialect: dialect, insideCounts: insideCounts || {}}); + path = path || []; + insideCounts = insideCounts || {}; - path = path || []; - insideCounts = insideCounts || {}; + path.push(tree); + tree.slice(1).forEach(function (n) { + var tagName = n[0]; + insideCounts[tagName] = (insideCounts[tagName] || 0) + 1; + parseTree(n, path, insideCounts); + insideCounts[tagName] = insideCounts[tagName] - 1; + }); + path.pop(); + } + return tree; +} - path.push(tree); - tree.slice(1).forEach(function (n) { - var tagName = n[0]; - insideCounts[tagName] = (insideCounts[tagName] || 0) + 1; - parseTree(n, path, insideCounts); - insideCounts[tagName] = insideCounts[tagName] - 1; - }); - path.pop(); - } - return tree; - }; +/** + Returns true if there's an invalid word boundary for a match. + + @method invalidBoundary + @param {Object} args our arguments, including whether we care about boundaries + @param {Array} prev the previous content, if exists + @returns {Boolean} whether there is an invalid word boundary +**/ +function invalidBoundary(args, prev) { + + if (!args.wordBoundary && !args.spaceBoundary) { return; } + + var last = prev[prev.length - 1]; + if (typeof last !== "string") { return; } + + if (args.wordBoundary && (last.match(/(\w|\/)$/))) { return true; } + if (args.spaceBoundary && (!last.match(/\s$/))) { return true; } +} /** An object used for rendering our dialects. @@ -110,7 +89,281 @@ Discourse.Dialect = { dialect.options = opts; var tree = parser.toHTMLTree(text, 'Discourse'); return parser.renderJsonML(parseTree(tree)); + }, + + /** + The simplest kind of replacement possible. Replace a stirng token with JsonML. + + For example to replace all occurrances of :) with a smile image: + + ```javascript + Discourse.Dialect.inlineReplace(':)', function (text) { + return ['img', {src: '/images/smile.png'}]; + }); + + ``` + + @method inlineReplace + @param {String} token The token we want to replace + @param {Function} emitter A function that emits the JsonML for the replacement. + **/ + inlineReplace: function(token, emitter) { + dialect.inline[token] = function(text, match, prev) { + return [token.length, emitter.call(this, token)]; + }; + }, + + /** + Matches inline using a regular expression. The emitter function is passed + the matches from the regular expression. + + For example, this auto links URLs: + + ```javascript + Discourse.Dialect.inlineRegexp({ + matcher: /((?:https?:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.])(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^`!()\[\]{};:'".,<>?«»“”‘’\s]))/gm, + spaceBoundary: true, + + emitter: function(matches) { + var url = matches[1]; + return ['a', {href: url}, url]; + } + }); + ``` + + @method inlineRegexp + @param {Object} args Our replacement options + @param {Function} [opts.emitter] The function that will be called with the contents and regular expresison match and returns JsonML. + @param {String} [opts.start] The starting token we want to find + @param {String} [opts.matcher] The regular expression to match + @param {Boolean} [opts.wordBoundary] If true, the match must be on a word boundary + @param {Boolean} [opts.spaceBoundary] If true, the match must be on a sppace boundary + **/ + inlineRegexp: function(args) { + dialect.inline[args.start] = function(text, match, prev) { + if (invalidBoundary(args, prev)) { return; } + + args.matcher.lastIndex = 0; + var m = args.matcher.exec(text); + if (m) { + var result = args.emitter.call(this, m); + if (result) { + return [m[0].length, result]; + } + } + }; + }, + + /** + Handles inline replacements surrounded by tokens. + + For example, to handle markdown style bold. Note we use `concat` on the array because + the contents are JsonML too since we didn't pass `rawContents` as true. This supports + recursive markup. + + ```javascript + + Discourse.Dialect.inlineBetween({ + between: '**', + wordBoundary: true. + emitter: function(contents) { + return ['strong'].concat(contents); + } + }); + ``` + + @method inlineBetween + @param {Object} args Our replacement options + @param {Function} [opts.emitter] The function that will be called with the contents and returns JsonML. + @param {String} [opts.start] The starting token we want to find + @param {String} [opts.stop] The ending token we want to find + @param {String} [opts.between] A shortcut for when the `start` and `stop` are the same. + @param {Boolean} [opts.rawContents] If true, the contents between the tokens will not be parsed. + @param {Boolean} [opts.wordBoundary] If true, the match must be on a word boundary + @param {Boolean} [opts.spaceBoundary] If true, the match must be on a sppace boundary + **/ + inlineBetween: function(args) { + var start = args.start || args.between, + stop = args.stop || args.between, + startLength = start.length; + + dialect.inline[start] = function(text, match, prev) { + if (invalidBoundary(args, prev)) { return; } + + var endPos = text.indexOf(stop, startLength); + if (endPos === -1) { return; } + + var between = text.slice(startLength, endPos); + + // If rawcontents is set, don't process inline + if (!args.rawContents) { + between = this.processInline(between); + } + + var contents = args.emitter.call(this, between); + if (contents) { + return [endPos+stop.length, contents]; + } + }; + }, + + /** + Replaces a block of text between a start and stop. As opposed to inline, these + might span multiple lines. + + Here's an example that takes the content between `[code]` ... `[/code]` and + puts them inside a `pre` tag: + + ```javascript + Discourse.Dialect.replaceBlock({ + start: /(\[code\])([\s\S]*)/igm, + stop: '[/code]', + + emitter: function(blockContents) { + return ['p', ['pre'].concat(blockContents)]; + } + }); + ``` + + @method replaceBlock + @param {Object} args Our replacement options + @param {String} [opts.start] The starting regexp we want to find + @param {String} [opts.stop] The ending token we want to find + @param {Function} [opts.emitter] The emitting function to transform the contents of the block into jsonML + + **/ + replaceBlock: function(args) { + dialect.block[args.start.toString()] = function(block, next) { + args.start.lastIndex = 0; + var m = (args.start).exec(block); + if (!m) { return; } + + var startPos = block.indexOf(m[0]), + leading, + blockContents = [], + result = [], + lineNumber = block.lineNumber; + + if (startPos > 0) { + leading = block.slice(0, startPos); + lineNumber += (leading.split("\n").length - 1); + + var para = ['p']; + this.processInline(leading).forEach(function (l) { + para.push(l); + }); + + result.push(para); + } + + if (m[2]) { + next.unshift(MD.mk_block(m[2], null, lineNumber + 1)); + } + + lineNumber++; + while (next.length > 0) { + var b = next.shift(), + blockLine = b.lineNumber, + diff = ((typeof blockLine === "undefined") ? lineNumber : blockLine) - lineNumber; + + var endFound = b.indexOf(args.stop), + leadingContents = b.slice(0, endFound), + trailingContents = b.slice(endFound+args.stop.length); + + for (var i=1; i 0) { - leading = block.slice(0, startPos); - lineNumber += (leading.split("\n").length - 1); - - var para = ['p']; - this.processInline(leading).forEach(function (l) { - para.push(l); - }); - - result.push(para); - } - - if (m[2]) { next.unshift(MD.mk_block(m[2], null, lineNumber + 1)); } - - lineNumber++; - while (next.length > 0) { - var b = next.shift(), - blockLine = b.lineNumber, - diff = ((typeof blockLine === "undefined") ? lineNumber : blockLine) - lineNumber; - - var endFound = b.indexOf('```'), - leadingCode = b.slice(0, endFound), - trailingCode = b.slice(endFound+3); - - for (var i=1; i 0) { return; } - - if (node.length > 1) { - for (var j=1; j 0) { - spliceInstructions.push(split[i]); - if (i !== split.length-1) { spliceInstructions.push(['br']); } - } - } - node.splice.apply(node, spliceInstructions); - } - } - } - } - } - } +// Ensure that content in a code block is fully escaped. This way it's not white listed +// and we can use HTML and Javascript examples. +Discourse.Dialect.postProcessTag('code', function (contents) { + return Handlebars.Utils.escapeExpression(contents); }); diff --git a/app/assets/javascripts/discourse/dialects/mention_dialect.js b/app/assets/javascripts/discourse/dialects/mention_dialect.js index 914e52dd7a0..66dc10fb5a5 100644 --- a/app/assets/javascripts/discourse/dialects/mention_dialect.js +++ b/app/assets/javascripts/discourse/dialects/mention_dialect.js @@ -2,47 +2,20 @@ Supports Discourse's custom @mention syntax for calling out a user in a post. It will add a special class to them, and create a link if the user is found in a local map. - - @event register - @namespace Discourse.Dialect **/ -Discourse.Dialect.on("register", function(event) { +Discourse.Dialect.inlineRegexp({ + start: '@', + matcher: /^(@[A-Za-z0-9][A-Za-z0-9_]{2,14})/m, + wordBoundary: true, - var dialect = event.dialect, - MD = event.MD; + emitter: function(matches) { + var username = matches[1], + mentionLookup = this.dialect.options.mentionLookup || Discourse.Mention.lookupCache; - /** - Parses out @username mentions. - - @method parseMentions - @param {String} text the text match - @param {Array} match the match found - @param {Array} prev the previous jsonML - @return {Array} an array containing how many chars we've replaced and the jsonML content for it. - @namespace Discourse.Dialect - **/ - dialect.inline['@'] = function parseMentions(text, match, prev) { - - // We only care about mentions on word boundaries - if (prev && (prev.length > 0)) { - var last = prev[prev.length - 1]; - if (typeof last === "string" && (!last.match(/\W$/))) { return; } + if (mentionLookup(username.substr(1))) { + return ['a', {'class': 'mention', href: Discourse.getURL("/users/") + username.substr(1).toLowerCase()}, username]; + } else { + return ['span', {'class': 'mention'}, username]; } - - var pattern = /^(@[A-Za-z0-9][A-Za-z0-9_]{2,14})(?=(\W|$))/m, - m = pattern.exec(text); - - if (m) { - var username = m[1], - mentionLookup = dialect.options.mentionLookup || Discourse.Mention.lookupCache; - - if (mentionLookup(username.substr(1))) { - return [username.length, ['a', {'class': 'mention', href: Discourse.getURL("/users/") + username.substr(1).toLowerCase()}, username]]; - } else { - return [username.length, ['span', {'class': 'mention'}, username]]; - } - } - - }; - -}); + } +}); \ No newline at end of file diff --git a/app/assets/javascripts/discourse/dialects/newline_dialect.js b/app/assets/javascripts/discourse/dialects/newline_dialect.js index 5859a3ed5df..1d51d4b9cd2 100644 --- a/app/assets/javascripts/discourse/dialects/newline_dialect.js +++ b/app/assets/javascripts/discourse/dialects/newline_dialect.js @@ -1,42 +1,32 @@ /** - Support for the newline behavior in markdown that most expect. - - @event parseNode - @namespace Discourse.Dialect + Support for the newline behavior in markdown that most expect. Look through all text nodes + in the tree, replace any new lines with `br`s. **/ -Discourse.Dialect.on("parseNode", function(event) { - var node = event.node, - opts = event.dialect.options, +Discourse.Dialect.postProcessText(function (text, event) { + var opts = event.dialect.options, insideCounts = event.insideCounts, linebreaks = opts.traditional_markdown_linebreaks || Discourse.SiteSettings.traditional_markdown_linebreaks; - if (!linebreaks) { - // We don't add line breaks inside a pre - if (insideCounts.pre > 0) { return; } + if (linebreaks || (insideCounts.pre > 0)) { return; } - if (node.length > 1) { - for (var j=1; j` + return [['br']]; + } else { - if (typeof textContent === "string") { - - if (textContent === "\n") { - node[j] = ['br']; - } else { - var split = textContent.split(/\n+/); - if (split.length) { - var spliceInstructions = [j, 1]; - for (var i=0; i 0) { - spliceInstructions.push(split[i]); - if (i !== split.length-1) { spliceInstructions.push(['br']); } - } - } - node.splice.apply(node, spliceInstructions); - } - } + // If the text node contains new lines, perhaps with text between them, insert the + // `
` tags. + var split = text.split(/\n+/); + if (split.length) { + var replacement = []; + for (var i=0; i 0) { + replacement.push(split[i]); + if (i !== split.length-1) { replacement.push(['br']); } } } + return replacement; } } + }); \ No newline at end of file diff --git a/app/assets/javascripts/discourse/dialects/quote_dialect.js b/app/assets/javascripts/discourse/dialects/quote_dialect.js new file mode 100644 index 00000000000..7bece862c04 --- /dev/null +++ b/app/assets/javascripts/discourse/dialects/quote_dialect.js @@ -0,0 +1,62 @@ +/** + Support for quoting other users. +**/ +Discourse.Dialect.replaceBlock({ + start: new RegExp("\\[quote=?([^\\[\\]]+)?\\]([\\s\\S]*)", "igm"), + stop: '[/quote]', + emitter: function(blockContents, matches, options) { + + var paramsString = matches[1].replace(/\"/g, ''), + params = {'class': 'quote'}, + paramsSplit = paramsString.split(/\, */), + username = paramsSplit[0]; + + paramsSplit.forEach(function(p,i) { + if (i > 0) { + var assignment = p.split(':'); + if (assignment[0] && assignment[1]) { + params['data-' + assignment[0]] = assignment[1].trim(); + } + } + }); + + var avatarImg; + if (options.lookupAvatarByPostNumber) { + // client-side, we can retrieve the avatar from the post + var postNumber = parseInt(params['data-post'], 10); + avatarImg = options.lookupAvatarByPostNumber(postNumber); + } else if (options.lookupAvatar) { + // server-side, we need to lookup the avatar from the username + avatarImg = options.lookupAvatar(username); + } + + var contents = this.processInline(blockContents.join(" \n \n")); + contents.unshift('blockquote'); + + return ['p', ['aside', params, + ['div', {'class': 'title'}, + ['div', {'class': 'quote-controls'}], + avatarImg ? avatarImg : "", + I18n.t('user.said', {username: username}) + ], + contents + ]]; + } +}); + +Discourse.Dialect.on("parseNode", function(event) { + var node = event.node, + path = event.path; + + // Make sure any quotes are followed by a
. The formatting looks weird otherwise. + if (node[0] === 'aside' && node[1] && node[1]['class'] === 'quote') { + var parent = path[path.length - 1], + location = parent.indexOf(node)+1, + trailing = parent.slice(location); + + if (trailing.length) { + parent.splice(location, 0, ['br']); + } + } + +}); \ No newline at end of file diff --git a/app/assets/javascripts/discourse/helpers/grouped_each.js b/app/assets/javascripts/discourse/helpers/grouped_each.js index f0632acae86..218232acad7 100644 --- a/app/assets/javascripts/discourse/helpers/grouped_each.js +++ b/app/assets/javascripts/discourse/helpers/grouped_each.js @@ -78,8 +78,6 @@ DiscourseGroupedEach.prototype = { template = this.template; data.insideEach = true; - data.insideGroup = true; - for (var i = 0; i < contentLength; i++) { template(content.objectAt(i), { data: data }); } @@ -124,5 +122,6 @@ Ember.Handlebars.registerHelper('groupedEach', function(path, options) { } options.hash.dataSourceBinding = path; + options.data.insideGroup = true; new DiscourseGroupedEach(this, path, options).render(); }); \ No newline at end of file diff --git a/app/assets/javascripts/discourse/mixins/selected_posts_count.js b/app/assets/javascripts/discourse/mixins/selected_posts_count.js index e9e6b5496c1..e30cfdf40fe 100644 --- a/app/assets/javascripts/discourse/mixins/selected_posts_count.js +++ b/app/assets/javascripts/discourse/mixins/selected_posts_count.js @@ -11,10 +11,15 @@ Discourse.SelectedPostsCount = Em.Mixin.create({ selectedPostsCount: function() { if (this.get('allPostsSelected')) return this.get('posts_count') || this.get('topic.posts_count'); - if (!this.get('selectedPosts')) return 0; + var sum = this.get('selectedPosts.length') || 0; + if (this.get('selectedReplies')) { + this.get('selectedReplies').forEach(function (p) { + sum += p.get('reply_count') || 0; + }); + } - return this.get('selectedPosts.length'); - }.property('selectedPosts.length', 'allPostsSelected') + return sum; + }.property('selectedPosts.length', 'allPostsSelected', 'selectedReplies.length') }); diff --git a/app/assets/javascripts/discourse/models/composer.js b/app/assets/javascripts/discourse/models/composer.js index 4b6046ba6ce..bb33e645616 100644 --- a/app/assets/javascripts/discourse/models/composer.js +++ b/app/assets/javascripts/discourse/models/composer.js @@ -442,6 +442,7 @@ Discourse.Composer = Discourse.Model.extend({ postStream = this.get('topic.postStream'), addedToStream = false; + // Build the post object var createdPost = Discourse.Post.create({ raw: this.get('reply'), @@ -482,6 +483,8 @@ Discourse.Composer = Discourse.Model.extend({ var composer = this; return Ember.Deferred.promise(function(promise) { + + composer.set('composeState', SAVING); createdPost.save(function(result) { var addedPost = false, saving = true; @@ -515,8 +518,16 @@ Discourse.Composer = Discourse.Model.extend({ if (postStream) { postStream.undoPost(createdPost); } - promise.reject($.parseJSON(error.responseText).errors[0]); composer.set('composeState', OPEN); + // TODO extract error handling code + var parsedError; + try { + parsedError = $.parseJSON(error.responseText).errors[0]; + } + catch(ex) { + parsedError = "Unknown error saving post, try again. Error: " + error.status + " " + error.statusText; + } + promise.reject(parsedError); }); }); }, diff --git a/app/assets/javascripts/discourse/models/post.js b/app/assets/javascripts/discourse/models/post.js index 1305a33201c..1927a769ed6 100644 --- a/app/assets/javascripts/discourse/models/post.js +++ b/app/assets/javascripts/discourse/models/post.js @@ -30,6 +30,7 @@ Discourse.Post = Discourse.Model.extend({ deletedViaTopic: Em.computed.and('firstPost', 'topic.deleted_at'), deleted: Em.computed.or('deleted_at', 'deletedViaTopic'), notDeleted: Em.computed.not('deleted'), + userDeleted: Em.computed.empty('user_id'), postDeletedBy: function() { if (this.get('firstPost')) { return this.get('topic.deleted_by'); } @@ -224,17 +225,18 @@ Discourse.Post = Discourse.Model.extend({ }, /** - Deletes a post + Changes the state of the post to be deleted. Does not call the server, that should be + done elsewhere. - @method destroy - @param {Discourse.User} deleted_by The user deleting the post + @method setDeletedState + @param {Discourse.User} deletedBy The user deleting the post **/ - destroy: function(deleted_by) { + setDeletedState: function(deletedBy) { // Moderators can delete posts. Regular users can only trigger a deleted at message. - if (deleted_by.get('staff')) { + if (deletedBy.get('staff')) { this.setProperties({ deleted_at: new Date(), - deleted_by: deleted_by, + deleted_by: deletedBy, can_delete: false }); } else { @@ -247,7 +249,16 @@ Discourse.Post = Discourse.Model.extend({ user_deleted: true }); } + }, + /** + Deletes a post + + @method destroy + @param {Discourse.User} deletedBy The user deleting the post + **/ + destroy: function(deletedBy) { + this.setDeletedState(deletedBy); return Discourse.ajax("/posts/" + (this.get('id')), { type: 'DELETE' }); }, @@ -327,8 +338,7 @@ Discourse.Post = Discourse.Model.extend({ // Whether to show replies directly below showRepliesBelow: function() { - var reply_count, topic; - reply_count = this.get('reply_count'); + var reply_count = this.get('reply_count'); // We don't show replies if there aren't any if (reply_count === 0) return false; @@ -340,7 +350,7 @@ Discourse.Post = Discourse.Model.extend({ if (reply_count > 1) return true; // If we have *exactly* one reply, we have to consider if it's directly below us - topic = this.get('topic'); + var topic = this.get('topic'); return !topic.isReplyDirectlyBelow(this); }.property('reply_count'), @@ -376,11 +386,12 @@ Discourse.Post.reopenClass({ return result; }, - deleteMany: function(posts) { + deleteMany: function(selectedPosts, selectedReplies) { return Discourse.ajax("/posts/destroy_many", { type: 'DELETE', data: { - post_ids: posts.map(function(p) { return p.get('id'); }) + post_ids: selectedPosts.map(function(p) { return p.get('id'); }), + reply_post_ids: selectedReplies.map(function(p) { return p.get('id'); }) } }); }, diff --git a/app/assets/javascripts/discourse/models/user.js b/app/assets/javascripts/discourse/models/user.js index 4abb65886f6..c367dd87573 100644 --- a/app/assets/javascripts/discourse/models/user.js +++ b/app/assets/javascripts/discourse/models/user.js @@ -28,7 +28,11 @@ Discourse.User = Discourse.Model.extend({ searchContext: function() { - return ({ type: 'user', id: this.get('username_lower'), user: this }); + return { + type: 'user', + id: this.get('username_lower'), + user: this + }; }.property('username_lower'), /** @@ -101,7 +105,7 @@ Discourse.User = Discourse.Model.extend({ @returns Result of ajax call **/ changeUsername: function(newUsername) { - return Discourse.ajax("/users/" + (this.get('username_lower')) + "/preferences/username", { + return Discourse.ajax("/users/" + this.get('username_lower') + "/preferences/username", { type: 'PUT', data: { new_username: newUsername } }); @@ -115,7 +119,7 @@ Discourse.User = Discourse.Model.extend({ @returns Result of ajax call **/ changeEmail: function(email) { - return Discourse.ajax("/users/" + (this.get('username_lower')) + "/preferences/email", { + return Discourse.ajax("/users/" + this.get('username_lower') + "/preferences/email", { type: 'PUT', data: { email: email } }); @@ -173,9 +177,7 @@ Discourse.User = Discourse.Model.extend({ changePassword: function() { return Discourse.ajax("/session/forgot_password", { dataType: 'json', - data: { - login: this.get('username') - }, + data: { login: this.get('username') }, type: 'POST' }); }, @@ -266,11 +268,14 @@ Discourse.User = Discourse.Model.extend({ Change avatar selection @method toggleAvatarSelection + @param {Boolean} useUploadedAvatar true if the user is using the uploaded avatar @returns {Promise} the result of the toggle avatar selection */ - toggleAvatarSelection: function() { - var data = { use_uploaded_avatar: this.get("use_uploaded_avatar") }; - return Discourse.ajax("/users/" + this.get("username") + "/preferences/avatar/toggle", { type: 'PUT', data: data }); + toggleAvatarSelection: function(useUploadedAvatar) { + return Discourse.ajax("/users/" + this.get("username_lower") + "/preferences/avatar/toggle", { + type: 'PUT', + data: { use_uploaded_avatar: useUploadedAvatar } + }); } }); diff --git a/app/assets/javascripts/discourse/routes/application_routes.js b/app/assets/javascripts/discourse/routes/application_routes.js index 5eadf489a7a..5772dc6a3db 100644 --- a/app/assets/javascripts/discourse/routes/application_routes.js +++ b/app/assets/javascripts/discourse/routes/application_routes.js @@ -10,7 +10,7 @@ Discourse.Route.buildRoutes(function() { // Topic routes this.resource('topic', { path: '/t/:slug/:id' }, function() { this.route('fromParams', { path: '/' }); - this.route('fromParams', { path: '/:nearPost' }); + this.route('fromParamsNear', { path: '/:nearPost' }); }); // Generate static page routes @@ -50,7 +50,8 @@ Discourse.Route.buildRoutes(function() { }); this.resource('userPrivateMessages', { path: '/private-messages' }, function() { - this.route('sent', {path: '/messages-sent'}); + this.route('mine', {path: '/mine'}); + this.route('unread', {path: '/unread'}); }); this.resource('preferences', { path: '/preferences' }, function() { diff --git a/app/assets/javascripts/discourse/routes/preferences_routes.js b/app/assets/javascripts/discourse/routes/preferences_routes.js index e623cf3c4c5..e337a43a81b 100644 --- a/app/assets/javascripts/discourse/routes/preferences_routes.js +++ b/app/assets/javascripts/discourse/routes/preferences_routes.js @@ -18,35 +18,29 @@ Discourse.PreferencesRoute = Discourse.RestrictedUserRoute.extend({ events: { showAvatarSelector: function() { Discourse.Route.showModal(this, 'avatarSelector'); - var user = this.modelFor("user"); - console.log(user); - this.controllerFor("avatarSelector").setProperties(user.getProperties( - "username", - "email", - "has_uploaded_avatar", - "use_uploaded_avatar", - "gravatar_template", - "uploaded_avatar_template" - )); + // all the properties needed for displaying the avatar selector modal + var avatarSelector = this.modelFor('user').getProperties( + 'username', 'email', + 'has_uploaded_avatar', 'use_uploaded_avatar', + 'gravatar_template', 'uploaded_avatar_template'); + this.controllerFor('avatarSelector').setProperties(avatarSelector); }, saveAvatarSelection: function() { - var user = this.modelFor("user"); - var avatar = this.controllerFor("avatarSelector"); + var user = this.modelFor('user'); + var avatarSelector = this.controllerFor('avatarSelector'); // sends the information to the server if it has changed - if (avatar.get("use_uploaded_avatar") !== user.get("use_uploaded_avatar")) { user.toggleAvatarSelection(); } - // saves the data back - user.setProperties(avatar.getProperties( - "has_uploaded_avatar", - "use_uploaded_avatar", - "gravatar_template", - "uploaded_avatar_template" - )); - if (avatar.get("use_uploaded_avatar")) { - user.set("avatar_template", avatar.get("uploaded_avatar_template")); - } else { - user.set("avatar_template", avatar.get("gravatar_template")); + if (avatarSelector.get('use_uploaded_avatar') !== user.get('use_uploaded_avatar')) { + user.toggleAvatarSelection(avatarSelector.get('use_uploaded_avatar')); } + // saves the data back + user.setProperties(avatarSelector.getProperties( + 'has_uploaded_avatar', + 'use_uploaded_avatar', + 'gravatar_template', + 'uploaded_avatar_template' + )); + user.set('avatar_template', avatarSelector.get('avatarTemplate')); } } }); diff --git a/app/assets/javascripts/discourse/routes/topic_from_params_route.js b/app/assets/javascripts/discourse/routes/topic_from_params_route.js index 368e5444f31..0b8a1455e4b 100644 --- a/app/assets/javascripts/discourse/routes/topic_from_params_route.js +++ b/app/assets/javascripts/discourse/routes/topic_from_params_route.js @@ -58,4 +58,5 @@ Discourse.TopicFromParamsRoute = Discourse.Route.extend({ }); +Discourse.TopicFromParamsNearRoute = Discourse.TopicFromParamsRoute; diff --git a/app/assets/javascripts/discourse/routes/user_routes.js b/app/assets/javascripts/discourse/routes/user_routes.js index 9ff40c4b188..ab8d8699bf4 100644 --- a/app/assets/javascripts/discourse/routes/user_routes.js +++ b/app/assets/javascripts/discourse/routes/user_routes.js @@ -171,33 +171,26 @@ Discourse.UserTopicListRoute = Discourse.Route.extend({ } }); -Discourse.UserPrivateMessagesIndexRoute = Discourse.UserTopicListRoute.extend({ - userActionType: Discourse.UserAction.TYPES.messages_received, +function createPMRoute(viewName, path, type) { + return Discourse.UserTopicListRoute.extend({ + userActionType: Discourse.UserAction.TYPES.messages_received, - model: function() { - return Discourse.TopicList.find('topics/private-messages/' + this.modelFor('user').get('username_lower')); - }, + model: function() { + return Discourse.TopicList.find('topics/' + path + '/' + this.modelFor('user').get('username_lower')); + }, - setupController: function(controller, model) { - this._super(controller, model); - controller.set('hideCategories', true); - this.controllerFor('userActivity').set('pmView', 'index'); - } + setupController: function(controller, model) { + this._super(controller, model); + controller.set('hideCategories', true); + this.controllerFor('userActivity').set('pmView', viewName); + } + }); +} -}); -Discourse.UserPrivateMessagesSentRoute = Discourse.UserTopicListRoute.extend({ - userActionType: Discourse.UserAction.TYPES.messages_sent, +Discourse.UserPrivateMessagesIndexRoute = createPMRoute('index', 'private-messages'); +Discourse.UserPrivateMessagesMineRoute = createPMRoute('mine', 'private-messages-sent'); +Discourse.UserPrivateMessagesUnreadRoute = createPMRoute('unread', 'private-messages-unread'); - model: function() { - return Discourse.TopicList.find('topics/private-messages-sent/' + this.modelFor('user').get('username_lower')); - }, - - setupController: function(controller, model) { - this._super(controller, model); - controller.set('hideCategories', true); - this.controllerFor('userActivity').set('pmView', 'sent'); - } -}); Discourse.UserActivityTopicsRoute = Discourse.UserTopicListRoute.extend({ userActionType: Discourse.UserAction.TYPES.topics, @@ -205,7 +198,6 @@ Discourse.UserActivityTopicsRoute = Discourse.UserTopicListRoute.extend({ model: function() { return Discourse.TopicList.find('topics/created-by/' + this.modelFor('user').get('username_lower')); } - }); Discourse.UserActivityFavoritesRoute = Discourse.UserTopicListRoute.extend({ diff --git a/app/assets/javascripts/discourse/templates/modal/avatar_selector.js.handlebars b/app/assets/javascripts/discourse/templates/modal/avatar_selector.js.handlebars index 4548c091b79..63fcf8e34d1 100644 --- a/app/assets/javascripts/discourse/templates/modal/avatar_selector.js.handlebars +++ b/app/assets/javascripts/discourse/templates/modal/avatar_selector.js.handlebars @@ -1,12 +1,12 @@