diff --git a/app/assets/javascripts/admin/controllers/admin_flags_controller.js b/app/assets/javascripts/admin/controllers/admin_flags_controller.js index f1843e93d2a..36cb05c3c89 100644 --- a/app/assets/javascripts/admin/controllers/admin_flags_controller.js +++ b/app/assets/javascripts/admin/controllers/admin_flags_controller.js @@ -40,6 +40,7 @@ Discourse.AdminFlagsController = Ember.ArrayController.extend({ bootbox.alert(Em.String.i18n("admin.flags.error")); }); }, + /** Deletes a post diff --git a/app/assets/javascripts/discourse.js b/app/assets/javascripts/discourse.js index c2e8be02abc..fa09ab10204 100644 --- a/app/assets/javascripts/discourse.js +++ b/app/assets/javascripts/discourse.js @@ -331,10 +331,6 @@ Discourse = Ember.Application.createWithMixins({ Discourse.MessageBus.start(); Discourse.KeyValueStore.init("discourse_", Discourse.MessageBus); - // Don't remove site settings for now. It seems on some browsers the route - // tries to use it after it has been removed - // PreloadStore.remove('siteSettings'); - // Developer specific functions Discourse.Development.setupProbes(); Discourse.Development.observeLiveChanges(); diff --git a/app/assets/javascripts/discourse/components/url.js b/app/assets/javascripts/discourse/components/url.js index 08869b6770b..0ab91a3264f 100644 --- a/app/assets/javascripts/discourse/components/url.js +++ b/app/assets/javascripts/discourse/components/url.js @@ -5,7 +5,7 @@ @namespace Discourse @module Discourse **/ -Discourse.URL = { +Discourse.URL = Em.Object.createWithMixins({ // Used for matching a topic TOPIC_REGEXP: /\/t\/([^\/]+)\/(\d+)\/?(\d+)?/, @@ -23,7 +23,7 @@ Discourse.URL = { **/ router: function() { return Discourse.__container__.lookup('router:main'); - }, + }.property(), /** Browser aware replaceState. Will only be invoked if the browser supports it. @@ -43,7 +43,8 @@ Discourse.URL = { // while URLs are loading. For example, while a topic loads it sets `currentPost` // which triggers a replaceState even though the topic hasn't fully loaded yet! Em.run.next(function() { - Discourse.URL.router().get('location').replaceURL(path); + var location = Discourse.URL.get('router.location'); + if (location.replaceURL) { location.replaceURL(path); } }); } }, @@ -85,10 +86,16 @@ Discourse.URL = { if (oldTopicId === newTopicId) { Discourse.URL.replaceState(path); var topicController = Discourse.__container__.lookup('controller:topic'); - var opts = { trackVisit: false }; + var opts = { }; if (newMatches[3]) opts.nearPost = newMatches[3]; - topicController.cancelFilter(); - topicController.loadPosts(opts); + + var postStream = topicController.get('postStream'); + postStream.refresh(opts).then(function() { + topicController.setProperties({ + currentPost: opts.nearPost || 1, + progressPosition: opts.nearPost || 1 + }); + }); // Abort routing, we have replaced our state. return; @@ -102,11 +109,18 @@ Discourse.URL = { // Be wary of looking up the router. In this case, we have links in our // HTML, say form compiled markdown posts, that need to be routed. - var router = this.router(); + var router = this.get('router'); router.router.updateURL(path); return router.handleURL(path); }, + /** + Replaces the query parameters in the URL. Use no parameters to clear them. + + @method replaceQueryParams + **/ + queryParams: Em.computed.alias('router.location.queryParams'), + /** @private @@ -131,4 +145,4 @@ Discourse.URL = { window.location = Discourse.getURL(url); } -}; +}); 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 425a75d62be..87031a5562c 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 @@ -10,15 +10,15 @@ Discourse.EditTopicAutoCloseController = Discourse.ObjectController.extend(Discourse.ModalFunctionality, { setDays: function() { - if( this.get('auto_close_at') ) { - var closeTime = new Date( this.get('auto_close_at') ); + 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()); } } else { - this.set('auto_close_days', ''); + this.set('details.auto_close_days', ''); } - }.observes('auto_close_at'), + }.observes('details.auto_close_at'), saveAutoClose: function() { this.setAutoClose( parseFloat(this.get('auto_close_days')) ); @@ -36,7 +36,7 @@ Discourse.EditTopicAutoCloseController = Discourse.ObjectController.extend(Disco dataType: 'html', // no custom errors, jquery 1.9 enforces json data: { auto_close_days: days > 0 ? days : null } }).then(function(){ - editTopicAutoCloseController.set('auto_close_at', moment().add('days', days).format()); + editTopicAutoCloseController.set('details.auto_close_at', moment().add('days', days).format()); }, function (error) { bootbox.alert(Em.String.i18n('generic_error')); }); diff --git a/app/assets/javascripts/discourse/controllers/invite_private_controller.js b/app/assets/javascripts/discourse/controllers/invite_private_controller.js index 2fbfac27420..a3320da57fe 100644 --- a/app/assets/javascripts/discourse/controllers/invite_private_controller.js +++ b/app/assets/javascripts/discourse/controllers/invite_private_controller.js @@ -40,7 +40,7 @@ Discourse.InvitePrivateController = Discourse.ObjectController.extend(Discourse. invitePrivateController.set('finished', true); if(result && result.user) { - invitePrivateController.get('content.allowed_users').pushObject(result.user); + invitePrivateController.get('content.details.allowed_users').pushObject(result.user); } }, function() { // Failure diff --git a/app/assets/javascripts/discourse/controllers/quote_button_controller.js b/app/assets/javascripts/discourse/controllers/quote_button_controller.js index ebcace7f89d..bfd4e2e38b7 100644 --- a/app/assets/javascripts/discourse/controllers/quote_button_controller.js +++ b/app/assets/javascripts/discourse/controllers/quote_button_controller.js @@ -34,7 +34,7 @@ Discourse.QuoteButtonController = Discourse.Controller.extend({ if (!Discourse.User.current()) return; // don't display the "quote-reply" button if we can't create a post - if (!this.get('controllers.topic.content.can_create_post')) return; + if (!this.get('controllers.topic.model.details.can_create_post')) return; var selection = window.getSelection(); // no selections diff --git a/app/assets/javascripts/discourse/controllers/topic_controller.js b/app/assets/javascripts/discourse/controllers/topic_controller.js index 5bcc284a874..8dd5c64acf2 100644 --- a/app/assets/javascripts/discourse/controllers/topic_controller.js +++ b/app/assets/javascripts/discourse/controllers/topic_controller.js @@ -7,24 +7,28 @@ @module Discourse **/ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.SelectedPostsCount, { - userFilters: new Em.Set(), multiSelect: false, - bestOf: false, summaryCollapsed: true, - loading: false, - loadingBelow: false, - loadingAbove: false, needs: ['header', 'modal', 'composer', 'quoteButton'], allPostsSelected: false, selectedPosts: new Em.Set(), + editingTopic: false, + + jumpTopDisabled: function() { + return this.get('currentPost') === 1; + }.property('currentPost'), + + jumpBottomDisabled: function() { + return this.get('currentPost') === this.get('highest_post_number'); + }.property('currentPost'), canMergeTopic: function() { - if (!this.get('can_move_posts')) return false; + if (!this.get('details.can_move_posts')) return false; return (this.get('selectedPostsCount') > 0); }.property('selectedPostsCount'), canSplitTopic: function() { - if (!this.get('can_move_posts')) return false; + if (!this.get('details.can_move_posts')) return false; if (this.get('allPostsSelected')) return false; return (this.get('selectedPostsCount') > 0); }.property('selectedPostsCount'), @@ -64,11 +68,11 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected }.observes('multiSelect'), hideProgress: function() { - if (!this.get('content.loaded')) return true; + if (!this.get('postStream.loaded')) return true; if (!this.get('currentPost')) return true; - if (this.get('content.filtered_posts_count') < 2) return true; + if (this.get('postStream.filteredPostsCount') < 2) return true; return false; - }.property('content.loaded', 'currentPost', 'content.filtered_posts_count'), + }.property('postStream.loaded', 'currentPost', 'postStream.filteredPostsCount'), selectPost: function(post) { var selectedPosts = this.get('selectedPosts'); @@ -107,6 +111,58 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected this.toggleProperty('summaryCollapsed'); }, + editTopic: function() { + if (!this.get('details.can_edit')) return false; + + this.setProperties({ + editingTopic: true, + newTitle: this.get('title'), + newCategoryId: this.get('category_id') + }); + return false; + }, + + // close editing mode + cancelEditingTopic: function() { + this.set('editingTopic', false); + }, + + finishedEditingTopic: function() { + var topicController = this; + if (this.get('editingTopic')) { + + var topic = this.get('model'); + + // manually update the titles & category + topic.setProperties({ + title: this.get('newTitle'), + category_id: parseInt(this.get('newCategoryId'), 10), + fancy_title: this.get('newTitle') + }); + + // save the modifications + topic.save().then(function(result){ + // update the title if it has been changed (cleaned up) server-side + var title = result.basic_topic.fancy_title; + topic.setProperties({ + title: title, + fancy_title: title + }); + + }, function(error) { + topicController.set('editingTopic', true); + if (error && error.responseText) { + bootbox.alert($.parseJSON(error.responseText).errors[0]); + } else { + bootbox.alert(Em.String.i18n('generic_error')); + } + }); + + // close editing mode + topicController.set('editingTopic', false); + } + }, + deleteSelected: function() { var topicController = this; bootbox.confirm(Em.String.i18n("post.delete.confirm", { count: this.get('selectedPostsCount')}), function(result) { @@ -126,25 +182,14 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected }, jumpTop: function() { - if (this.get('bestOf')) { - Discourse.TopicView.scrollTo(this.get('id'), this.get('posts')[0].get('post_number')); - } else { - Discourse.URL.routeTo(this.get('url')); - } + Discourse.URL.routeTo(this.get('url')); }, jumpBottom: function() { - if (this.get('bestOf')) { - Discourse.TopicView.scrollTo(this.get('id'), _.last(this.get('posts')).get('post_number')); - } else { - Discourse.URL.routeTo(this.get('lastPostUrl')); - } + Discourse.URL.routeTo(this.get('lastPostUrl')); }, - cancelFilter: function() { - this.set('bestOf', false); - this.get('userFilters').clear(); - }, + replyAsNewTopic: function(post) { // TODO shut down topic draft cleanly if it exists ... @@ -182,95 +227,18 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected } }, - toggleParticipant: function(user) { - this.set('bestOf', false); - var username = Em.get(user, 'username'); - var userFilters = this.get('userFilters'); - if (userFilters.contains(username)) { - userFilters.remove(username); - } else { - userFilters.add(username); - } - }, - /** - Show or hide the bottom bar, depending on our filter options. + Toggle a participant for filtering - @method updateBottomBar + @method toggleParticipant **/ - updateBottomBar: function() { - - var postFilters = this.get('postFilters'); - - if (postFilters.bestOf) { - this.set('filterDesc', Em.String.i18n("topic.filters.best_of", { - n_best_posts: Em.String.i18n("topic.filters.n_best_posts", { count: this.get('filtered_posts_count') }), - of_n_posts: Em.String.i18n("topic.filters.of_n_posts", { count: this.get('posts_count') }) - })); - } else if (postFilters.userFilters.length > 0) { - this.set('filterDesc', Em.String.i18n("topic.filters.user", { - n_posts: Em.String.i18n("topic.filters.n_posts", { count: this.get('filtered_posts_count') }), - by_n_users: Em.String.i18n("topic.filters.by_n_users", { count: postFilters.userFilters.length }) - })); - } else { - // Hide the bottom bar - $('#topic-filter').slideUp(); - return; - } - - $('#topic-filter').slideDown(); + toggleParticipant: function(user) { + this.get('postStream').toggleParticipant(Em.get(user, 'username')); }, - enableBestOf: function(e) { - this.set('bestOf', true); - this.get('userFilters').clear(); - }, - - postFilters: function() { - if (this.get('bestOf') === true) return { bestOf: true }; - return { userFilters: this.get('userFilters') }; - }.property('userFilters.[]', 'bestOf'), - - loadPosts: function(opts) { - var topicController = this; - this.get('content').loadPosts(opts).then(function () { - Em.run.scheduleOnce('afterRender', topicController, 'updateBottomBar'); - }); - }, - - reloadPosts: function() { - var topic = this.get('content'); - if (!topic) return; - - var posts = topic.get('posts'); - if (!posts) return; - - // Leave the first post -- we keep it above the filter controls - posts.removeAt(1, posts.length - 1); - - this.set('loadingBelow', true); - - var topicController = this; - var postFilters = this.get('postFilters'); - return Discourse.Topic.find(this.get('id'), postFilters).then(function(result) { - var first = result.posts[0]; - if (first) { - topicController.set('currentPost', first.post_number); - } - $('#topic-progress .solid').data('progress', false); - _.each(result.posts,function(p) { - // Skip the first post - if (p.post_number === 1) return; - posts.pushObject(Discourse.Post.create(p, topic)); - }); - - Em.run.scheduleOnce('afterRender', topicController, 'updateBottomBar'); - - topicController.set('filtered_posts_count', result.filtered_posts_count); - topicController.set('loadingBelow', false); - topicController.set('seenBottom', false); - }); - }.observes('postFilters'), + showFavoriteButton: function() { + return Discourse.User.current() && !this.get('isPrivateMessage'); + }.property('isPrivateMessage'), deleteTopic: function() { var topicController = this; @@ -327,23 +295,13 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected bus.subscribe("/topic/" + (this.get('id')), function(data) { var topic = topicController.get('model'); if (data.notification_level_change) { - topic.set('notification_level', data.notification_level_change); - topic.set('notifications_reason_id', data.notifications_reason_id); - return; - } - var posts = topic.get('posts'); - if (posts.some(function(p) { - return p.get('post_number') === data.post_number; - })) { + topic.set('details.notification_level', data.notification_level_change); + topic.set('details.notifications_reason_id', data.notifications_reason_id); return; } - // Robin, TODO when a message comes in we need to figure out if it even goes - // in this view ... for now fixed the general case - topic.set('filtered_posts_count', topic.get('filtered_posts_count') + 1); - topic.set('highest_post_number', data.post_number); - topic.set('last_poster', data.user); - topic.set('last_posted_at', data.created_at); + // Add the new post into the stream + topicController.get('postStream').triggerNewPostInStream(data.id); }); }, @@ -424,15 +382,8 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected post.destroy(); }, - postRendered: function(post) { - var onPostRendered = this.get('onPostRendered'); - if (onPostRendered) { - onPostRendered(post); - } - }, - removeAllowedUser: function(username) { - this.get('model').removeAllowedUser(username); + this.get('details').removeAllowedUser(username); } }); diff --git a/app/assets/javascripts/discourse/models/composer.js b/app/assets/javascripts/discourse/models/composer.js index 8f6d2d3ca5d..b3dc1732644 100644 --- a/app/assets/javascripts/discourse/models/composer.js +++ b/app/assets/javascripts/discourse/models/composer.js @@ -357,70 +357,40 @@ Discourse.Composer = Discourse.Model.extend({ var post = this.get('post'), topic = this.get('topic'), currentUser = Discourse.User.current(), + postStream = this.get('topic.postStream'), addedToStream = false; - // The post number we'll probably get from the server - var probablePostNumber = this.get('topic.highest_post_number') + 1; - // Build the post object var createdPost = Discourse.Post.create({ - raw: this.get('reply'), - title: this.get('title'), - category: this.get('categoryName'), - topic_id: this.get('topic.id'), - reply_to_post_number: post ? post.get('post_number') : null, - imageSizes: opts.imageSizes, - post_number: probablePostNumber, - index: probablePostNumber, - cooked: $('#wmd-preview').html(), - reply_count: 0, - display_username: currentUser.get('name'), - username: currentUser.get('username'), - user_id: currentUser.get('id'), - metaData: this.get('metaData'), - archetype: this.get('archetypeId'), - post_type: Discourse.Site.instance().get('post_types.regular'), - target_usernames: this.get('targetUsernames'), - actions_summary: Em.A(), - moderator: currentUser.get('moderator'), - yours: true, - newPost: true, - auto_close_days: this.get('auto_close_days') - }); + raw: this.get('reply'), + title: this.get('title'), + category: this.get('categoryName'), + topic_id: this.get('topic.id'), + reply_to_post_number: post ? post.get('post_number') : null, + imageSizes: opts.imageSizes, + cooked: $('#wmd-preview').html(), + reply_count: 0, + display_username: currentUser.get('name'), + username: currentUser.get('username'), + user_id: currentUser.get('id'), + metaData: this.get('metaData'), + archetype: this.get('archetypeId'), + post_type: Discourse.Site.instance().get('post_types.regular'), + target_usernames: this.get('targetUsernames'), + actions_summary: Em.A(), + moderator: currentUser.get('moderator'), + yours: true, + newPost: true, + auto_close_days: this.get('auto_close_days') + }); // If we're in a topic, we can append the post instantly. - if (topic) { - - // Increase the reply count + if (postStream) { + // If it's in reply to another post, increase the reply count if (post) { post.set('reply_count', (post.get('reply_count') || 0) + 1); } - topic.set('posts_count', topic.get('posts_count') + 1); - - // Update last post - topic.set('last_posted_at', new Date()); - topic.set('highest_post_number', createdPost.get('post_number')); - topic.set('last_poster', Discourse.User.current()); - topic.set('filtered_posts_count', topic.get('filtered_posts_count') + 1); - - // Set the topic view for the new post - createdPost.set('topic', topic); - createdPost.set('created_at', new Date()); - - // If we're near the end of the topic, load new posts - var lastPost = topic.posts[topic.posts.length-1]; - if (lastPost) { - var diff = topic.get('highest_post_number') - lastPost.get('post_number'); - - // If the new post is within a threshold of the end of the topic, - // add it and scroll there instead of adding the link. - - if (diff < 5) { - createdPost.set('scrollToAfterInsert', createdPost.get('post_number')); - topic.pushPosts([createdPost]); - addedToStream = true; - } - } + postStream.stagePost(createdPost, currentUser); } // Save callback @@ -430,11 +400,13 @@ Discourse.Composer = Discourse.Model.extend({ var addedPost = false, saving = true; - createdPost.updateFromSave(result); + createdPost.updateFromJson(result); if (topic) { // It's no longer a new post createdPost.set('newPost', false); topic.set('draft_sequence', result.draft_sequence); + postStream.commitPost(createdPost); + addedToStream = true; } else { // We created a new topic, let's show it. composer.set('composeState', CLOSED); @@ -448,12 +420,13 @@ Discourse.Composer = Discourse.Model.extend({ } else if (saving) { composer.set('composeState', SAVING); } + + return promise.resolve({ post: result }); }, function(error) { // If an error occurs - if (topic) { - topic.posts.removeObject(createdPost); - topic.set('filtered_posts_count', topic.get('filtered_posts_count') - 1); + if (postStream) { + postStream.undoPost(createdPost); } promise.reject($.parseJSON(error.responseText).errors[0]); composer.set('composeState', OPEN); diff --git a/app/assets/javascripts/discourse/models/post.js b/app/assets/javascripts/discourse/models/post.js index c395efa1ca0..4a2c734759d 100644 --- a/app/assets/javascripts/discourse/models/post.js +++ b/app/assets/javascripts/discourse/models/post.js @@ -15,9 +15,8 @@ Discourse.Post = Discourse.Model.extend({ return this.get('url') + (user ? '?u=' + user.get('username_lower') : ''); }.property('url'), - new_user: function() { - return this.get('trust_level') === 0; - }.property('trust_level'), + new_user: Em.computed.equal('trust_level', 0), + firstPost: Em.computed.equal('post_number', 1), url: function() { return Discourse.Utilities.postUrl(this.get('topic.slug') || this.get('topic_slug'), this.get('topic_id'), this.get('post_number')); @@ -35,14 +34,9 @@ Discourse.Post = Discourse.Model.extend({ return this.get('reply_to_user') && (this.get('reply_to_post_number') < (this.get('post_number') - 1)); }.property('reply_to_user', 'reply_to_post_number', 'post_number'), - firstPost: function() { - if (this.get('bestOfFirst') === true) return true; - return this.get('post_number') === 1; - }.property('post_number'), - byTopicCreator: function() { - return this.get('topic.created_by.id') === this.get('user_id'); - }.property('topic.created_by.id', 'user_id'), + return this.get('topic.details.created_by.id') === this.get('user_id'); + }.property('topic.details.created_by.id', 'user_id'), hasHistory: function() { return this.get('version') > 1; @@ -55,28 +49,23 @@ Discourse.Post = Discourse.Model.extend({ // The class for the read icon of the post. It starts with read-icon then adds 'seen' or // 'last-read' if the post has been seen or is the highest post number seen so far respectively. bookmarkClass: function() { - var result, topic; - result = 'read-icon'; + var result = 'read-icon'; if (this.get('bookmarked')) return result + ' bookmarked'; - topic = this.get('topic'); + + var topic = this.get('topic'); if (topic && topic.get('last_read_post_number') === this.get('post_number')) { - result += ' last-read'; - } else { - if (this.get('read')) { - result += ' seen'; - } else { - result += ' unseen'; - } + return result + ' last-read'; } - return result; + + return result + (this.get('read') ? ' seen' : ' unseen'); }.property('read', 'topic.last_read_post_number', 'bookmarked'), // Custom tooltips for the bookmark icons bookmarkTooltip: function() { - var topic; if (this.get('bookmarked')) return Em.String.i18n('bookmarks.created'); if (!this.get('read')) return ""; - topic = this.get('topic'); + + var topic = this.get('topic'); if (topic && topic.get('last_read_post_number') === this.get('post_number')) { return Em.String.i18n('bookmarks.last_read'); } @@ -123,9 +112,9 @@ Discourse.Post = Discourse.Model.extend({ }.property('updated_at'), flagsAvailable: function() { - var _this = this; - var flags = Discourse.Site.instance().get('flagTypes').filter(function(item) { - return _this.get("actionByName." + (item.get('name_key')) + ".can_act"); + var post = this, + flags = Discourse.Site.instance().get('flagTypes').filter(function(item) { + return post.get("actionByName." + (item.get('name_key')) + ".can_act"); }); return flags; }.property('actions_summary.@each.can_act'), @@ -142,7 +131,6 @@ Discourse.Post = Discourse.Model.extend({ // Save a post and call the callback when done. save: function(complete, error) { - var data, metaData; if (!this.get('newPost')) { // We're updating a post return Discourse.ajax("/posts/" + (this.get('id')), { @@ -163,7 +151,7 @@ Discourse.Post = Discourse.Model.extend({ } else { // We're saving a post - data = { + var data = { raw: this.get('raw'), topic_id: this.get('topic_id'), reply_to_post_number: this.get('reply_to_post_number'), @@ -175,11 +163,13 @@ Discourse.Post = Discourse.Model.extend({ auto_close_days: this.get('auto_close_days') }; + var metaData = this.get('metaData'); // Put the metaData into the request - if (metaData = this.get('metaData')) { + if (metaData) { data.meta_data = {}; Ember.keys(metaData).forEach(function(key) { data.meta_data[key] = metaData.get(key); }); } + return Discourse.ajax("/posts", { type: 'POST', data: data @@ -201,14 +191,35 @@ Discourse.Post = Discourse.Model.extend({ return Discourse.ajax("/posts/" + (this.get('id')), { type: 'DELETE' }); }, - // Update the properties of this post from an obj, ignoring cooked as we should already - // have that rendered. - updateFromSave: function(obj) { + /** + Updates a post from another's attributes. This will normally happen when a post is loading but + is already found in an identity map. + @method updateFromPost + @param {Discourse.Post} otherPost The post we're updating from + **/ + updateFromPost: function(otherPost) { var post = this; + Object.keys(otherPost).forEach(function (key) { + var value = otherPost[key]; + if (typeof value !== "function") { + post.set(key, value); + } + }); + }, + + /** + Updates a post from a JSON packet. This is normally done after the post is saved to refresh any + attributes. + + @method updateFromJson + @param {Object} obj The Json data to update with + **/ + updateFromJson: function(obj) { + if (!obj) return; // Update all the properties - if (!obj) return; + var post = this; _.each(obj, function(val,key) { if (key !== 'actions_summary'){ if (val) { @@ -255,7 +266,7 @@ Discourse.Post = Discourse.Model.extend({ }, // Whether to show replies directly below - showRepliesBelow: (function() { + showRepliesBelow: function() { var reply_count, _ref; reply_count = this.get('reply_count'); @@ -272,15 +283,15 @@ Discourse.Post = Discourse.Model.extend({ if ((_ref = this.get('topic')) ? _ref.isReplyDirectlyBelow(this) : void 0) return false; return true; - }).property('reply_count') + }.property('reply_count') + }); Discourse.Post.reopenClass({ createActionSummary: function(result) { - var lookup; if (result.actions_summary) { - lookup = Em.Object.create(); + var lookup = Em.Object.create(); result.actions_summary = result.actions_summary.map(function(a) { a.post = result; a.actionType = Discourse.Site.instance().postActionTypeById(a.id); @@ -288,17 +299,16 @@ Discourse.Post.reopenClass({ lookup.set(a.actionType.get('name_key'), actionSummary); return actionSummary; }); - return result.set('actionByName', lookup); + result.set('actionByName', lookup); } }, - create: function(obj, topic) { + create: function(obj) { var result = this._super(obj); this.createActionSummary(result); if (obj && obj.reply_to_user) { result.set('reply_to_user', Discourse.User.create(obj.reply_to_user)); } - result.set('topic', topic); return result; }, diff --git a/app/assets/javascripts/discourse/models/post_stream.js b/app/assets/javascripts/discourse/models/post_stream.js new file mode 100644 index 00000000000..bb2c9f4bd81 --- /dev/null +++ b/app/assets/javascripts/discourse/models/post_stream.js @@ -0,0 +1,633 @@ +/** + We use this class to keep on top of streaming and filtering posts within a topic. + + @class PostStream + @extends Ember.Object + @namespace Discourse + @module Discourse +**/ +Discourse.PostStream = Em.Object.extend({ + + /** + Are we currently loading posts in any way? + + @property loading + **/ + loading: Em.computed.or('loadingAbove', 'loadingBelow', 'loadingFilter', 'stagingPost'), + + notLoading: Em.computed.not('loading'), + + filteredPostsCount: Em.computed.alias('stream.length'), + + /** + Have we loaded any posts? + + @property hasPosts + **/ + hasPosts: function() { + return this.get('posts.length') > 0; + }.property('posts.length'), + + /** + Do we have a stream list of post ids? + + @property hasStream + **/ + hasStream: function() { + return this.get('filteredPostsCount') > 0; + }.property('filteredPostsCount'), + + /** + Can we append more posts to our current stream? + + @property canAppendMore + **/ + canAppendMore: Em.computed.and('notLoading', 'hasPosts', 'lastPostNotLoaded'), + + + /** + Can we prepend more posts to our current stream? + + @property canPrependMore + **/ + canPrependMore: Em.computed.and('notLoading', 'hasPosts', 'firstPostNotLoaded'), + + /** + Have we loaded the first post in the stream? + + @property firstPostLoaded + **/ + firstPostLoaded: function() { + if (!this.get('hasLoadedData')) { return false; } + return !!this.get('posts').findProperty('id', this.get('stream')[0]); + }.property('hasLoadedData', 'posts.[]', 'stream.@each'), + + firstPostNotLoaded: Em.computed.not('firstPostLoaded'), + + /** + Have we loaded the last post in the stream? + + @property lastPostLoaded + **/ + lastPostLoaded: function() { + if (!this.get('hasLoadedData')) { return false; } + return !!this.get('posts').findProperty('id', _.last(this.get('stream'))); + }.property('hasLoadedData', 'posts.[]', 'stream.@each'), + + lastPostNotLoaded: Em.computed.not('lastPostLoaded'), + + /** + Returns a JS Object of current stream filter options. It should match the query + params for the stream. + + @property streamFilters + **/ + streamFilters: function() { + var result = {}; + if (this.get('bestOf')) { result.filter = "best_of"; } + + var userFilters = this.get('userFilters'); + if (userFilters) { + var userFiltersArray = this.get('userFilters').toArray(); + if (userFiltersArray.length > 0) { result.username_filters = userFiltersArray; } + } + + return result; + }.property('userFilters.[]', 'bestOf'), + + /** + The text describing the current filters. For display in the pop up at the bottom of the + screen. + + @property filterDesc + **/ + filterDesc: function() { + var streamFilters = this.get('streamFilters'); + + if (streamFilters.filter && streamFilters.filter === "best_of") { + return Em.String.i18n("topic.filters.best_of", { + n_best_posts: Em.String.i18n("topic.filters.n_best_posts", { count: this.get('filteredPostsCount') }), + of_n_posts: Em.String.i18n("topic.filters.of_n_posts", { count: this.get('topic.posts_count') }) + }); + } else if (streamFilters.username_filters) { + return Em.String.i18n("topic.filters.user", { + n_posts: Em.String.i18n("topic.filters.n_posts", { count: this.get('filteredPostsCount') }), + by_n_users: Em.String.i18n("topic.filters.by_n_users", { count: streamFilters.username_filters.length }) + }); + } + return ""; + }.property('streamFilters.[]', 'topic.posts_count', 'posts.length'), + + hasNoFilters: Em.computed.empty('filterDesc'), + + + /** + Returns the window of posts above the current set in the stream, bound to the top of the stream. + This is the collection we'll ask for when scrolling upwards. + + @property previousWindow + **/ + previousWindow: function() { + // If we can't find the last post loaded, bail + var firstPost = _.first(this.get('posts')); + if (!firstPost) { return []; } + + // Find the index of the last post loaded, if not found, bail + var stream = this.get('stream'); + var firstIndex = this.indexOf(firstPost); + if (firstIndex === -1) { return []; } + + var startIndex = firstIndex - Discourse.SiteSettings.posts_per_page; + if (startIndex < 0) { startIndex = 0; } + return stream.slice(startIndex, firstIndex); + + }.property('posts.@each', 'stream.@each'), + + /** + Returns the window of posts below the current set in the stream, bound by the bottom of the + stream. This is the collection we use when scrolling downwards. + + @property nextWindow + **/ + nextWindow: function() { + // If we can't find the last post loaded, bail + var lastPost = _.last(this.get('posts')); + if (!lastPost) { return []; } + + // Find the index of the last post loaded, if not found, bail + var stream = this.get('stream'); + var lastIndex = this.indexOf(lastPost); + if (lastIndex === -1) { return []; } + if ((lastIndex + 1) >= this.get('filteredPostsCount')) { return []; } + + // find our window of posts + return stream.slice(lastIndex+1, lastIndex+Discourse.SiteSettings.posts_per_page+1); + }.property('posts.@each', 'stream.@each'), + + + /** + Cancel any active filters on the stream and refresh it. + + @method cancelFilter + @returns {Ember.Deferred} a promise that resolves when the filter has been cancelled. + **/ + cancelFilter: function() { + this.set('bestOf', false); + this.get('userFilters').clear(); + return this.refresh(); + }, + + /** + Toggle best of mode on the stream. + + @method toggleBestOf + @returns {Ember.Deferred} a promise that resolves when the best of stream has loaded. + **/ + toggleBestOf: function() { + this.toggleProperty('bestOf'); + this.refresh(); + }, + + /** + Filter the stream to a particular user. + + @method toggleParticipant + @returns {Ember.Deferred} a promise that resolves when the filtered stream has loaded. + **/ + toggleParticipant: function(username) { + var userFilters = this.get('userFilters'); + if (userFilters.contains(username)) { + userFilters.remove(username); + } else { + userFilters.add(username); + } + return this.refresh(); + }, + + /** + Loads a new set of posts into the stream. If you provide a `nearPost` option and the post + is already loaded, it will simply scroll there and load nothing. + + @method refresh + @param {Object} opts Options for loading the stream + @param {Integer} opts.nearPost The post we want to find other posts near to. + @param {Boolean} opts.track_visit Whether or not to track this as a visit to a topic. + @returns {Ember.Deferred} a promise that is resolved when the posts have been inserted into the stream. + **/ + refresh: function(opts) { + opts = opts || {}; + opts.nearPost = parseInt(opts.nearPost, 10); + + var topic = this.get('topic'); + var postStream = this; + + // Do we already have the post in our list of posts? Jump there. + var postWeWant = this.get('posts').findProperty('post_number', opts.nearPost); + if (postWeWant) { + Discourse.TopicView.jumpToPost(topic.get('id'), opts.nearPost); + return Ember.Deferred.create(function(p) { p.reject(); }); + } + + // TODO: if we have all the posts in the filter, don't go to the server for them. + postStream.set('loadingFilter', true); + + opts = _.merge(opts, postStream.get('streamFilters')); + + // Request a topicView + return Discourse.PostStream.loadTopicView(topic.get('id'), opts).then(function (json) { + topic.updateFromJson(json); + postStream.updateFromJson(json.post_stream); + postStream.setProperties({ loadingFilter: false, loaded: true }); + + if (opts.nearPost) { + Discourse.TopicView.jumpToPost(topic.get('id'), opts.nearPost); + } + + Discourse.URL.set('queryParams', postStream.get('streamFilters')); + }, function(result) { + postStream.errorLoading(result.status); + }); + }, + hasLoadedData: Em.computed.and('hasPosts', 'hasStream'), + + /** + Appends the next window of posts to the stream. Call it when scrolling downwards. + + @method appendMore + @returns {Ember.Deferred} a promise that's resolved when the posts have been added. + **/ + appendMore: function() { + var postStream = this, + rejectedPromise = Ember.Deferred.create(function(p) { p.reject(); }); + + // Make sure we can append more posts + if (!postStream.get('canAppendMore')) { return rejectedPromise; } + + var postIds = postStream.get('nextWindow'); + if (Ember.isEmpty(postIds)) { return rejectedPromise; } + + postStream.set('loadingBelow', true); + return postStream.findPostsByIds(postIds).then(function(posts) { + posts.forEach(function(p) { + postStream.appendPost(p); + }); + postStream.set('loadingBelow', false); + }); + }, + + /** + Prepend the previous window of posts to the stream. Call it when scrolling upwards. + + @method appendMore + @returns {Ember.Deferred} a promise that's resolved when the posts have been added. + **/ + prependMore: function() { + var postStream = this, + rejectedPromise = Ember.Deferred.create(function(p) { p.reject(); }); + + // Make sure we can append more posts + if (!postStream.get('canPrependMore')) { return rejectedPromise; } + + var postIds = postStream.get('previousWindow'); + if (Ember.isEmpty(postIds)) { return rejectedPromise; } + + postStream.set('loadingAbove', true); + return postStream.findPostsByIds(postIds.reverse()).then(function(posts) { + posts.forEach(function(p) { + postStream.prependPost(p); + }); + postStream.set('loadingAbove', false); + }); + }, + + /** + Stage a post for insertion in the stream. It should be rendered right away under the + assumption that the post will succeed. We can then `commitPost` when it succeeds or + `undoPost` when it fails. + + @method stagePost + @param {Discourse.Post} the post to stage in the stream + @param {Discourse.User} the user creating the post + **/ + stagePost: function(post, user) { + var topic = this.get('topic'); + + topic.setProperties({ + posts_count: (topic.get('posts_count') || 0) + 1, + last_posted_at: new Date(), + 'details.last_poster': user, + highest_post_number: (topic.get('highest_post_number') || 0) + 1 + }); + this.set('stagingPost', true); + + post.setProperties({ + post_number: topic.get('highest_post_number'), + topic: topic, + created_at: new Date() + }); + + // If we're at the end of the stream, add the post + if (this.get('lastPostLoaded')) { + this.appendPost(post); + } + }, + + /** + Commit the post we staged. Call this after a save succeeds. + + @method commitPost + @param {Discourse.Post} the post we saved in the stream. + **/ + commitPost: function(post) { + this.appendPost(post); + this.get('stream').pushObject(post.get('id')); + this.set('stagingPost', false); + }, + + /** + Undo a post we've staged in the stream. Remove it from being rendered and revert the + state we changed. + + @method undoPost + @param {Discourse.Post} the post to undo from the stream + **/ + undoPost: function(post) { + this.posts.removeObject(post); + + var topic = this.get('topic'); + + this.set('stagingPost', false); + + topic.setProperties({ + highest_post_number: (topic.get('highest_post_number') || 0) - 1, + posts_count: (topic.get('posts_count') || 0) - 1 + }); + }, + + /** + Prepends a single post to the stream. + + @method prependPost + @param {Discourse.Post} post The post we're prepending + @returns {Discourse.Post} the post that was inserted. + **/ + prependPost: function(post) { + this.get('posts').unshiftObject(this.storePost(post)); + return post; + }, + + /** + Appends a single post into the stream. + + @method appendPost + @param {Discourse.Post} post The post we're appending + @returns {Discourse.Post} the post that was inserted. + **/ + appendPost: function(post) { + this.get('posts').addObject(this.storePost(post)); + return post; + }, + + /** + Returns a post from the identity map if it's been inserted. + + @method findLoadedPost + @param {Integer} id The post we want from the identity map. + @returns {Discourse.Post} the post that was inserted. + **/ + findLoadedPost: function(id) { + return this.get('postIdentityMap').get(id); + }, + + /** + Finds and adds a post to the stream by id. Typically this would happen if we receive a message + from the message bus indicating there's a new post. We'll only insert it if we currently + have no filters. + + @method triggerNewPostInStream + @param {Integer} postId The id of the new post to be inserted into the stream + **/ + triggerNewPostInStream: function(postId) { + if (!postId) { return; } + + // We only trigger if there are no filters active + if (!this.get('hasNoFilters')) { return; } + + var lastPostLoaded = this.get('lastPostLoaded'); + + if (this.get('stream').indexOf(postId) === -1) { + this.get('stream').pushObject(postId); + if (lastPostLoaded) { this.appendMore(); } + } + }, + + /** + @private + + Given a JSON packet, update this stream and the posts that exist in it. + + @param {Object} postStreamData The JSON data we want to update from. + @method updateFromJson + **/ + updateFromJson: function(postStreamData) { + var postStream = this; + + var posts = this.get('posts'); + posts.clear(); + if (postStreamData) { + // Load posts if present + postStreamData.posts.forEach(function(p) { + postStream.appendPost(Discourse.Post.create(p)); + }); + delete postStreamData.posts; + + // Update our attributes + postStream.setProperties(postStreamData); + } + }, + + /** + @private + + Stores a post in our identity map, and sets up the references it needs to + find associated objects like the topic. It might return a different reference + than you supplied if the post has already been loaded. + + @method storePost + @param {Discourse.Post} post The post we're storing in the identity map + @returns {Discourse.Post} the post from the identity map + **/ + storePost: function(post) { + var postId = post.get('id'); + if (postId) { + var postIdentityMap = this.get('postIdentityMap'), + existing = postIdentityMap.get(post.get('id')); + + if (existing) { + // If the post is in the identity map, update it and return the old reference. + existing.updateFromPost(post); + return existing; + } + + post.set('topic', this.get('topic')); + postIdentityMap.set(post.get('id'), post); + } + return post; + }, + + /** + @private + + Given a list of postIds, returns a list of the posts we don't have in our + identity map and need to load. + + @method listUnloadedIds + @param {Array} postIds The post Ids we want to load from the server + @returns {Array} the array of postIds we don't have loaded. + **/ + listUnloadedIds: function(postIds) { + var unloaded = Em.A(), + postIdentityMap = this.get('postIdentityMap'); + postIds.forEach(function(p) { + if (!postIdentityMap.has(p)) { unloaded.pushObject(p); } + }); + return unloaded; + }, + + /** + @private + + Returns a list of posts in order requested, by id. + + @method findPostsByIds + @param {Array} postIds The post Ids we want to retrieve, in order. + @returns {Ember.Deferred} a promise that will resolve to the posts in the order requested. + **/ + findPostsByIds: function(postIds) { + var unloaded = this.listUnloadedIds(postIds), + postIdentityMap = this.get('postIdentityMap'); + + // Load our unloaded posts by id + return this.loadIntoIdentityMap(unloaded).then(function() { + return postIds.map(function (p) { + return postIdentityMap.get(p); + }); + }); + }, + + /** + @private + + Loads a list of posts from the server and inserts them into our identity map. + + @method loadIntoIdentityMap + @param {Array} postIds The post Ids we want to insert into the identity map. + @returns {Ember.Deferred} a promise that will resolve to the posts in the order requested. + **/ + loadIntoIdentityMap: function(postIds) { + + // If we don't want any posts, return a promise that resolves right away + if (Em.isEmpty(postIds)) { + return Ember.Deferred.promise(function (p) { p.resolve(); }); + } + + var url = "/t/" + this.get('topic.id') + "/posts.json", + data = { post_ids: postIds }, + postStream = this, + result = Em.A(); + + return Discourse.ajax(url, {data: data}).then(function(result) { + var posts = Em.get(result, "post_stream.posts"); + if (posts) { + posts.forEach(function (p) { + postStream.storePost(Discourse.Post.create(p)); + }); + } + }); + }, + + + /** + @private + + Returns the index of a particular post in the stream + + @method indexOf + @param {Discourse.Post} post The post we're looking for + **/ + indexOf: function(post) { + return this.get('stream').indexOf(post.get('id')); + }, + + /** + @private + + Handles an error loading a topic based on a HTTP status code. Updates + the text to the correct values. + + @method errorLoading + @param {Integer} status the HTTP status code + @param {Discourse.Topic} topic The topic instance we were trying to load + **/ + errorLoading: function(status) { + + var topic = this.get('topic'); + topic.set('loadingFilter', false); + topic.set('errorLoading', true); + + // If the result was 404 the post is not found + if (status === 404) { + topic.set('errorTitle', Em.String.i18n('topic.not_found.title')); + topic.set('message', Em.String.i18n('topic.not_found.description')); + return; + } + + // If the result is 403 it means invalid access + if (status === 403) { + topic.set('errorTitle', Em.String.i18n('topic.invalid_access.title')); + topic.set('message', Em.String.i18n('topic.invalid_access.description')); + return; + } + + // Otherwise supply a generic error message + topic.set('errorTitle', Em.String.i18n('topic.server_error.title')); + topic.set('message', Em.String.i18n('topic.server_error.description')); + } + +}); + + +Discourse.PostStream.reopenClass({ + + create: function(args) { + var postStream = this._super(args); + postStream.setProperties({ + posts: Em.A(), + stream: Em.A(), + userFilters: Em.Set.create(), + postIdentityMap: Em.Map.create(), + bestOf: false, + loaded: false, + loadingAbove: false, + loadingBelow: false, + loadingFilter: false, + stagingPost: false + }); + return postStream; + }, + + loadTopicView: function(topicId, args) { + var opts = _.merge({}, args); + var url = Discourse.getURL("/t/") + topicId; + if (opts.nearPost) { + url += "/" + opts.nearPost; + } + delete opts.nearPost; + + return PreloadStore.getAndRemove("topic_" + topicId, function() { + return Discourse.ajax(url + ".json", {data: opts}); + }); + + } + +}); diff --git a/app/assets/javascripts/discourse/models/topic.js b/app/assets/javascripts/discourse/models/topic.js index 68a09a32c22..56fdfb8f101 100644 --- a/app/assets/javascripts/discourse/models/topic.js +++ b/app/assets/javascripts/discourse/models/topic.js @@ -8,10 +8,13 @@ **/ Discourse.Topic = Discourse.Model.extend({ - fewParticipants: function() { - if (!this.present('participants')) return null; - return this.get('participants').slice(0, 3); - }.property('participants'), + postStream: function() { + return Discourse.PostStream.create({topic: this}); + }.property(), + + details: function() { + return Discourse.TopicDetails.create({topic: this}); + }.property(), canConvertToRegular: function() { var a = this.get('archetype'); @@ -34,8 +37,17 @@ Discourse.Topic = Discourse.Model.extend({ }.property('id'), category: function() { - return Discourse.Category.list().findProperty('name', this.get('categoryName')); - }.property('categoryName'), + var categoryId = this.get('category_id'); + if (categoryId) { + return Discourse.Category.list().findProperty('id', categoryId); + } + + var categoryName = this.get('categoryName'); + if (categoryName) { + return Discourse.Category.list().findProperty('name', categoryName); + } + return null; + }.property('category_id', 'categoryName'), shareUrl: function(){ var user = Discourse.User.current(); @@ -150,17 +162,6 @@ Discourse.Topic = Discourse.Model.extend({ }); }, - removeAllowedUser: function(username) { - var allowedUsers = this.get('allowed_users'); - - return Discourse.ajax("/t/" + this.get('id') + "/remove-allowed-user", { - type: 'PUT', - data: { username: username } - }).then(function(){ - allowedUsers.removeObject(allowedUsers.find(function(item){ return item.username === username; })); - }); - }, - favoriteTooltipKey: (function() { return this.get('starred') ? 'favorite.help.unstar' : 'favorite.help.star'; }).property('starred'), @@ -190,7 +191,7 @@ Discourse.Topic = Discourse.Model.extend({ // Save any changes we've made to the model save: function() { // Don't save unless we can - if (!this.get('can_edit')) return; + if (!this.get('details.can_edit')) return; return Discourse.ajax(this.get('url'), { type: 'PUT', @@ -218,138 +219,19 @@ Discourse.Topic = Discourse.Model.extend({ return Discourse.ajax("/t/" + (this.get('id')), { type: 'DELETE' }); }, - // Load the posts for this topic - loadPosts: function(opts) { + // Update our attributes from a JSON result + updateFromJson: function(json) { + this.get('details').updateFromJson(json.details); + + var keys = Object.keys(json); + keys.removeObject('details'); + keys.removeObject('post_stream'); + var topic = this; - - if (!opts) opts = {}; - - // Load the first post by default - if ((!opts.bestOf) && (!opts.nearPost)) opts.nearPost = 1; - - // If we already have that post in the DOM, jump to it. Return a promise - // that's already complete. - if (Discourse.TopicView.scrollTo(this.get('id'), opts.nearPost)) { - return Ember.Deferred.promise(function(promise) { promise.resolve(); }); - } - - // If loading the topic succeeded... - var afterTopicLoaded = function(result) { - - var closestPostNumber, lastPost, postDiff; - - // Update the slug if different - if (result.slug) topic.set('slug', result.slug); - - // If we want to scroll to a post that doesn't exist, just pop them to the closest - // one instead. This is likely happening due to a deleted post. - opts.nearPost = parseInt(opts.nearPost, 10); - closestPostNumber = 0; - postDiff = Number.MAX_VALUE; - _.each(result.posts,function(p) { - var diff = Math.abs(p.post_number - opts.nearPost); - if (diff < postDiff) { - postDiff = diff; - closestPostNumber = p.post_number; - if (diff === 0) return false; - } - }); - - opts.nearPost = closestPostNumber; - if (topic.get('participants')) { - topic.get('participants').clear(); - } - if (result.suggested_topics) { - topic.set('suggested_topics', Em.A()); - } - - topic.mergeAttributes(result, { suggested_topics: Discourse.Topic }); - topic.set('posts', Em.A()); - if (opts.trackVisit && result.draft && result.draft.length > 0) { - Discourse.openComposer({ - draft: Discourse.Draft.getLocal(result.draft_key, result.draft), - draftKey: result.draft_key, - draftSequence: result.draft_sequence, - topic: topic, - ignoreIfChanged: true - }); - } - - // Okay this is weird, but let's store the length of the next post when there - lastPost = null; - _.each(result.posts,function(p) { - p.scrollToAfterInsert = opts.nearPost; - var post = Discourse.Post.create(p); - post.set('topic', topic); - topic.get('posts').pushObject(post); - lastPost = post; - }); - - topic.set('allowed_users', Em.A(result.allowed_users)); - topic.set('loaded', true); - }; - - var errorLoadingTopic = function(result) { - - topic.set('errorLoading', true); - - // If the result was 404 the post is not found - if (result.status === 404) { - topic.set('errorTitle', Em.String.i18n('topic.not_found.title')); - topic.set('message', Em.String.i18n('topic.not_found.description')); - return; - } - - // If the result is 403 it means invalid access - if (result.status === 403) { - topic.set('errorTitle', Em.String.i18n('topic.invalid_access.title')); - topic.set('message', Em.String.i18n('topic.invalid_access.description')); - return; - } - - // Otherwise supply a generic error message - topic.set('errorTitle', Em.String.i18n('topic.server_error.title')); - topic.set('message', Em.String.i18n('topic.server_error.description')); - }; - - // Finally, call our find method - return Discourse.Topic.find(this.get('id'), { - nearPost: opts.nearPost, - bestOf: opts.bestOf, - trackVisit: opts.trackVisit - }).then(afterTopicLoaded, errorLoadingTopic); - }, - - notificationReasonText: function() { - var locale_string = "topic.notifications.reasons." + this.get('notification_level'); - if (typeof this.get('notifications_reason_id') === 'number') { - locale_string += "_" + this.get('notifications_reason_id'); - } - return Em.String.i18n(locale_string, { username: Discourse.User.current('username_lower') }); - }.property('notification_level', 'notifications_reason_id'), - - updateNotifications: function(v) { - this.set('notification_level', v); - this.set('notifications_reason_id', null); - return Discourse.ajax("/t/" + (this.get('id')) + "/notifications", { - type: 'POST', - data: { notification_level: v } + keys.forEach(function (key) { + topic.set(key, json[key]); }); - }, - // use to add post to topics protecting from dupes - pushPosts: function(newPosts) { - var map, posts; - map = {}; - posts = this.get('posts'); - _.each(posts,function(post) { - map["" + post.post_number] = true; - }); - _.each(newPosts,function(post) { - if (!map[post.get('post_number')]) { - posts.pushObject(post); - } - }); }, /** @@ -422,8 +304,6 @@ Discourse.Topic.reopenClass({ }, // Load a topic, but accepts a set of filters - // options: - // onLoad - the callback after the topic is loaded find: function(topicId, opts) { var data, promise, url; url = Discourse.getURL("/t/") + topicId; @@ -457,15 +337,7 @@ Discourse.Topic.reopenClass({ } // Check the preload store. If not, load it via JSON - return PreloadStore.getAndRemove("topic_" + topicId, function() { - return Discourse.ajax(url + ".json", {data: data}); - }).then(function(result) { - var first = result.posts[0]; - if (first && opts && opts.bestOf) { - first.bestOfFirst = true; - } - return result; - }); + return Discourse.ajax(url + ".json", {data: data}); }, mergeTopic: function(topicId, destinationTopicId) { @@ -488,24 +360,6 @@ Discourse.Topic.reopenClass({ promise.reject(); }); return promise; - }, - - create: function(obj, topicView) { - var result = this._super(obj); - - if (result.participants) { - result.participants = _.map(result.participants,function(u) { - return Discourse.User.create(u); - }); - result.fewParticipants = Em.A(); - _.each(result.participants,function(p) { - // TODO should not be hardcoded - if (result.fewParticipants.length >= 8) return false; - result.fewParticipants.pushObject(p); - }); - } - - return result; } }); diff --git a/app/assets/javascripts/discourse/models/topic_details.js b/app/assets/javascripts/discourse/models/topic_details.js new file mode 100644 index 00000000000..383974abcde --- /dev/null +++ b/app/assets/javascripts/discourse/models/topic_details.js @@ -0,0 +1,56 @@ +/** + A model representing a Topic's details that aren't always present, such as a list of participants. + When showing topics in lists and such this information should not be required. + + @class TopicDetails + @extends Discourse.Model + @namespace Discourse + @module Discourse +**/ +Discourse.TopicDetails = Discourse.Model.extend({ + loaded: false, + + updateFromJson: function(details) { + + if (details.allowed_users) { + details.allowed_users = details.allowed_users.map(function (u) { + return Discourse.User.create(u); + }); + } + + if (details.suggested_topics) { + details.suggested_topics = details.suggested_topics.map(function (st) { + return Discourse.Topic.create(st); + }); + } + + this.setProperties(details); + this.set('loaded', true); + + }, + + fewParticipants: function() { + if (!this.present('participants')) return null; + return this.get('participants').slice(0, 3); + }.property('participants'), + + + notificationReasonText: function() { + var locale_string = "topic.notifications.reasons." + this.get('notification_level'); + if (typeof this.get('notifications_reason_id') === 'number') { + locale_string += "_" + this.get('notifications_reason_id'); + } + return Em.String.i18n(locale_string, { username: Discourse.User.current('username_lower') }); + }.property('notification_level', 'notifications_reason_id'), + + + updateNotifications: function(v) { + this.set('notification_level', v); + this.set('notifications_reason_id', null); + return Discourse.ajax("/t/" + (this.get('topic.id')) + "/notifications", { + type: 'POST', + data: { notification_level: v } + }); + } + +}); \ No newline at end of file diff --git a/app/assets/javascripts/discourse/routes/application_routes.js b/app/assets/javascripts/discourse/routes/application_routes.js index d28b5d4291c..554e4767ef8 100644 --- a/app/assets/javascripts/discourse/routes/application_routes.js +++ b/app/assets/javascripts/discourse/routes/application_routes.js @@ -11,7 +11,6 @@ Discourse.Route.buildRoutes(function() { this.resource('topic', { path: '/t/:slug/:id' }, function() { this.route('fromParams', { path: '/' }); this.route('fromParams', { path: '/:nearPost' }); - this.route('bestOf', { path: '/best_of' }); }); // Generate static page routes diff --git a/app/assets/javascripts/discourse/routes/discourse_location.js b/app/assets/javascripts/discourse/routes/discourse_location.js index 6c29043b7b3..5c02aa4120e 100644 --- a/app/assets/javascripts/discourse/routes/discourse_location.js +++ b/app/assets/javascripts/discourse/routes/discourse_location.js @@ -7,6 +7,35 @@ var get = Ember.get, set = Ember.set; var popstateReady = false; +// Thanks: https://gist.github.com/kares/956897 +var re = /([^&=]+)=?([^&]*)/g; +var decode = function(str) { + return decodeURIComponent(str.replace(/\+/g, ' ')); +}; +$.parseParams = function(query) { + var params = {}, e; + if (query) { + if (query.substr(0, 1) === '?') { + query = query.substr(1); + } + + while (e = re.exec(query)) { + var k = decode(e[1]); + var v = decode(e[2]); + if (params[k] !== undefined) { + if (!$.isArray(params[k])) { + params[k] = [params[k]]; + } + params[k].push(v); + } else { + params[k] = v; + } + } + } + return params; +}; + + /** `Ember.DiscourseLocation` implements the location API using the browser's `history.pushState` API. @@ -16,6 +45,7 @@ var popstateReady = false; @extends Ember.Object */ Ember.DiscourseLocation = Ember.Object.extend({ + init: function() { set(this, 'location', get(this, 'location') || window.location); if ( $.inArray('state', $.event.props) < 0 ) { @@ -32,6 +62,12 @@ Ember.DiscourseLocation = Ember.Object.extend({ @method initState */ initState: function() { + + var location = this.get('location'); + if (location && location.search) { + this.set('queryParams', $.parseParams(location.search)); + } + this.replaceState(this.formatURL(this.getURL())); set(this, 'history', window.history); }, @@ -62,6 +98,7 @@ Ember.DiscourseLocation = Ember.Object.extend({ @param path {String} */ setURL: function(path) { + path = this.formatURL(path); if (this.getState() && this.getState().path !== path) { popstateReady = true; @@ -79,6 +116,7 @@ Ember.DiscourseLocation = Ember.Object.extend({ @param path {String} */ replaceURL: function(path) { + path = this.formatURL(path); if (this.getState() && this.getState().path !== path) { @@ -129,6 +167,21 @@ Ember.DiscourseLocation = Ember.Object.extend({ window.history.replaceState({ path: path }, null, path); }, + + queryParamsString: function() { + var params = this.get('queryParams'); + if (Em.isEmpty(params) || Em.isEmpty(Object.keys(params))) { + return ""; + } else { + return "?" + $.param(params).replace(/%5B/g, "[").replace(/%5D/g, "]"); + } + }.property('queryParams'), + + // When our query params change, update the URL + queryParamsStringChanged: function() { + this.replaceState(this.formatURL(this.getURL())); + }.observes('queryParamsString'), + /** @private @@ -182,7 +235,7 @@ Ember.DiscourseLocation = Ember.Object.extend({ url = url.substring(rootURL.length); } - return rootURL + url; + return rootURL + url + this.get('queryParamsString'); }, willDestroy: function() { diff --git a/app/assets/javascripts/discourse/routes/topic_best_of_route.js b/app/assets/javascripts/discourse/routes/topic_best_of_route.js deleted file mode 100644 index a2b0b99bfd6..00000000000 --- a/app/assets/javascripts/discourse/routes/topic_best_of_route.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - This route is used when a topic's "best of" filter is applied - - @class TopicBestOfRoute - @extends Discourse.Route - @namespace Discourse - @module Discourse -**/ -Discourse.TopicBestOfRoute = Discourse.Route.extend({ - - setupController: function(controller, params) { - var topicController; - params = params || {}; - params.trackVisit = true; - params.bestOf = true; - topicController = this.controllerFor('topic'); - topicController.cancelFilter(); - topicController.set('bestOf', true); - topicController.loadPosts(params); - } - -}); - - 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 82769d57494..38f14bc259c 100644 --- a/app/assets/javascripts/discourse/routes/topic_from_params_route.js +++ b/app/assets/javascripts/discourse/routes/topic_from_params_route.js @@ -10,11 +10,46 @@ Discourse.TopicFromParamsRoute = Discourse.Route.extend({ setupController: function(controller, params) { params = params || {}; - params.trackVisit = true; + params.track_visit = true; + + var topic = this.modelFor('topic'); + var postStream = topic.get('postStream'); + + var queryParams = Discourse.URL.get('queryParams'); + if (queryParams) { + // Set bestOf on the postStream if present + postStream.set('bestOf', Em.get(queryParams, 'filter') === 'best_of'); + + // Set any username filters on the postStream + var userFilters = Em.get(queryParams, 'username_filters[]'); + if (userFilters) { + if (typeof userFilters === "string") { userFilters = [userFilters]; } + userFilters.forEach(function (username) { + postStream.get('userFilters').add(username); + }); + } + } var topicController = this.controllerFor('topic'); - topicController.cancelFilter(); - topicController.loadPosts(params); + postStream.refresh(params).then(function () { + topicController.setProperties({ + currentPost: params.nearPost || 1, + progressPosition: params.nearPost || 1 + }); + + if (topic.present('draft')) { + Discourse.openComposer({ + draft: Discourse.Draft.getLocal(topic.get('draft_key'), topic.get('draft')), + draftKey: topic.get('draft_key'), + draftSequence: topic.get('draft_sequence'), + topic: topic, + ignoreIfChanged: true + }); + } + + }); + + } }); diff --git a/app/assets/javascripts/discourse/routes/topic_route.js b/app/assets/javascripts/discourse/routes/topic_route.js index 517c06243d3..6b3ac7f0bd3 100644 --- a/app/assets/javascripts/discourse/routes/topic_route.js +++ b/app/assets/javascripts/discourse/routes/topic_route.js @@ -60,11 +60,9 @@ Discourse.TopicRoute = Discourse.Route.extend({ }, model: function(params) { - var currentModel, _ref; - if (currentModel = (_ref = this.controllerFor('topic')) ? _ref.get('content') : void 0) { - if (currentModel.get('id') === parseInt(params.id, 10)) { - return currentModel; - } + var currentModel = this.modelFor('topic'); + if (currentModel && (currentModel.get('id') === parseInt(params.id, 10))) { + return currentModel; } return Discourse.Topic.create(params); }, @@ -85,23 +83,28 @@ Discourse.TopicRoute = Discourse.Route.extend({ // Clear the search context this.controllerFor('search').set('searchContext', null); - var headerController, topicController; - topicController = this.controllerFor('topic'); - topicController.cancelFilter(); - topicController.unsubscribe(); + var topicController = this.controllerFor('topic'); + var postStream = topicController.get('postStream'); + postStream.cancelFilter(); topicController.set('multiSelect', false); + topicController.unsubscribe(); this.controllerFor('composer').set('topic', null); Discourse.ScreenTrack.instance().stop(); + var headerController; if (headerController = this.controllerFor('header')) { headerController.set('topic', null); headerController.set('showExtraInfo', false); } + + // Clear any filters when we leave the route + Discourse.URL.set('queryParams', null); }, setupController: function(controller, model) { controller.set('model', model); + this.controllerFor('header').setProperties({ topic: model, showExtraInfo: false diff --git a/app/assets/javascripts/discourse/templates/header.js.handlebars b/app/assets/javascripts/discourse/templates/header.js.handlebars index 88ef66e5229..0a68ca98dd0 100644 --- a/app/assets/javascripts/discourse/templates/header.js.handlebars +++ b/app/assets/javascripts/discourse/templates/header.js.handlebars @@ -10,7 +10,7 @@ {{/if}}

- {{#if topic.fancy_title}} + {{#if topic.details.loaded}} {{topicStatus topic=topic}} {{{topic.fancy_title}}} {{else}} diff --git a/app/assets/javascripts/discourse/templates/list/topic_list_item.js.handlebars b/app/assets/javascripts/discourse/templates/list/topic_list_item.js.handlebars index 9f7a67801f7..0b05f97ceb9 100644 --- a/app/assets/javascripts/discourse/templates/list/topic_list_item.js.handlebars +++ b/app/assets/javascripts/discourse/templates/list/topic_list_item.js.handlebars @@ -55,7 +55,7 @@ {{#if like_count}} - {{like_count}} + {{like_count}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/post.js.handlebars b/app/assets/javascripts/discourse/templates/post.js.handlebars index c41e22f0d1f..ff89d2ee4de 100644 --- a/app/assets/javascripts/discourse/templates/post.js.handlebars +++ b/app/assets/javascripts/discourse/templates/post.js.handlebars @@ -60,7 +60,7 @@
{{collection contentBinding="internalLinks" itemViewClass="Discourse.PostLinkView" tagName="ul" classNames="post-links"}} - {{#if controller.can_reply_as_new_topic}} + {{#if controller.details.can_reply_as_new_topic}} {{i18n post.reply_as_new_topic}} {{/if}}
diff --git a/app/assets/javascripts/discourse/templates/suggested_topic.js.handlebars b/app/assets/javascripts/discourse/templates/suggested_topic.js.handlebars index 50138c8850c..c1aa4841202 100644 --- a/app/assets/javascripts/discourse/templates/suggested_topic.js.handlebars +++ b/app/assets/javascripts/discourse/templates/suggested_topic.js.handlebars @@ -1,7 +1,7 @@ {{#with view.content}} {{#group}} - {{{unbound fancy_title}}} + {{{unbound title}}} {{#if unread}} {{unbound unread}} {{/if}} @@ -19,7 +19,7 @@ {{#if like_count}} - {{like_count}} + {{like_count}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/topic.js.handlebars b/app/assets/javascripts/discourse/templates/topic.js.handlebars index 1a09bc300ac..45229c21193 100644 --- a/app/assets/javascripts/discourse/templates/topic.js.handlebars +++ b/app/assets/javascripts/discourse/templates/topic.js.handlebars @@ -1,140 +1,133 @@ -{{#if content}} - {{#if loaded}} +{{#if postStream.loaded}} - {{#if view.firstPostLoaded}} + {{#if postStream.firstPostLoaded}}
- {{#if view.showFavoriteButton}} - + + {{#if showFavoriteButton}} + {{/if}} - {{#if view.editingTopic}} - - {{categoryChooser valueAttribute="name" source=view.topic.categoryName}} + {{#if editingTopic}} + {{textField id='edit-title' value=newTitle}} + {{categoryChooser valueAttribute="id" value=newCategoryId source=category_id}} - - + + {{else}}

- {{#if view.topic.fancy_title}} - {{topicStatus topic=view.topic}} - {{{view.topic.fancy_title}}} - {{else}} - {{#if view.topic.errorLoading}} - {{view.topic.errorTitle}} - {{else}} - {{i18n topic.loading}} - {{/if}} + {{#if details.loaded}} + {{topicStatus topic=model}} + {{{fancy_title}}} {{/if}} {{categoryLink category}} - {{#if view.topic.can_edit}} - + {{#if details.can_edit}} + {{/if}}

{{/if}}
- {{/if}} + {{/if}} -
+
- {{view Discourse.SelectedPostsView}} -
-
-
-
- -
- - {{#if loadingAbove}} -
{{i18n loading}}
- {{/if}} - - {{collection itemViewClass="Discourse.PostView" contentBinding="posts" topicViewBinding="view"}} - - {{#if loadingBelow}} -
{{i18n loading}}
- {{/if}} + {{view Discourse.SelectedPostsView}} +
+
+
+
+
-
- {{#if loading}} - {{#unless loadingBelow}} -
{{i18n loading}}
- {{/unless}} - {{else}} - {{#if view.fullyLoaded}} - - {{view Discourse.TopicClosingView topicBinding="model"}} - - {{view Discourse.TopicFooterButtonsView topicBinding="model"}} - - {{#if suggested_topics.length}} -
- -

{{i18n suggested_topics.title}}

- -
- - - - - - - - - - - {{each suggested_topics itemTagName="tr" itemViewClass="Discourse.SuggestedTopicView"}} -
- {{i18n topic.title}} - {{i18n category_title}}{{i18n posts}}{{i18n likes}}{{i18n views}}{{i18n activity}}
-
-
-

{{{view.browseMoreMessage}}}

-
- {{/if}} - {{/if}} + {{#if postStream.loadingAbove}} +
{{i18n loading}}
{{/if}} + {{#unless postStream.loadingFilter}} + {{collection itemViewClass="Discourse.PostView" contentBinding="postStream.posts" topicViewBinding="view"}} + {{/unless}} -
-
+ {{#if postStream.loadingBelow}} +
{{i18n loading}}
+ {{/if}} +
+
+ {{#if postStream.loadingFilter}} +
{{i18n loading}}
+ {{else}} + {{#if postStream.lastPostLoaded}} + + {{view Discourse.TopicClosingView topicBinding="model"}} + {{view Discourse.TopicFooterButtonsView topicBinding="model"}} + + {{#if details.suggested_topics.length}} +
+ +

{{i18n suggested_topics.title}}

+ +
+ + + + + + + + + + + {{each details.suggested_topics itemTagName="tr" itemViewClass="Discourse.SuggestedTopicView"}} +
+ {{i18n topic.title}} + {{i18n category_title}}{{i18n posts}}{{i18n likes}}{{i18n views}}{{i18n activity}}
+
+
+

{{{view.browseMoreMessage}}}

+
+ {{/if}} + {{/if}} + {{/if}} + + +
+
+ +{{else}} + {{#if message}} +
+
+ +

{{message}}

+ +

+ {{#linkTo list.latest}}{{i18n topic.back_to_list}}{{/linkTo}} +

+
{{else}} - {{#if message}} -
-
- -

{{message}}

- -

- {{#linkTo list.latest}}{{i18n topic.back_to_list}}{{/linkTo}} -

-
- {{else}} -
-
{{i18n loading}}
-
- {{/if}} +
+
{{i18n loading}}
+
{{/if}} {{/if}} -