Refactor: Move Topic Details into better objects, identity map, tests, query string filters

This commit is contained in:
Robin Ward 2013-06-20 17:20:08 -04:00
parent d051e35000
commit 5770879472
56 changed files with 1854 additions and 1244 deletions

View file

@ -40,6 +40,7 @@ Discourse.AdminFlagsController = Ember.ArrayController.extend({
bootbox.alert(Em.String.i18n("admin.flags.error"));
});
},
/**
Deletes a post

View file

@ -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();

View file

@ -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);
}
};
});

View file

@ -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'));
});

View file

@ -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

View file

@ -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

View file

@ -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);
}
});

View file

@ -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);

View file

@ -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;
},

View file

@ -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});
});
}
});

View file

@ -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;
}
});

View file

@ -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 }
});
}
});

View file

@ -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

View file

@ -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() {

View file

@ -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);
}
});

View file

@ -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
});
}
});
}
});

View file

@ -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

View file

@ -10,7 +10,7 @@
<a {{bindAttr class=":star topic.starred:starred"}} {{action toggleStar}} href='#' {{bindAttr title="topic.favoriteTooltip"}}></a>
{{/if}}
<h1>
{{#if topic.fancy_title}}
{{#if topic.details.loaded}}
{{topicStatus topic=topic}}
<a class='topic-link' href='{{unbound topic.url}}'>{{{topic.fancy_title}}}</a>
{{else}}

View file

@ -55,7 +55,7 @@
<td class='num likes'>
{{#if like_count}}
<a href='{{url}}{{#if has_best_of}}/best_of{{/if}}' title='{{i18n topic.likes count="like_count"}}'>{{like_count}} <i class='icon-heart'></i></a>
<a href='{{url}}{{#if has_best_of}}?filter=best_of{{/if}}' title='{{i18n topic.likes count="like_count"}}'>{{like_count}} <i class='icon-heart'></i></a>
{{/if}}
</td>

View file

@ -60,7 +60,7 @@
<div class='span5 gutter'>
{{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}}
<a href='#' class='reply-new' {{action replyAsNewTopic this}}><i class='icon icon-plus'></i>{{i18n post.reply_as_new_topic}}</a>
{{/if}}
</div>

View file

@ -1,7 +1,7 @@
{{#with view.content}}
{{#group}}
<td class='main-link'>
<a class='title' href="{{unbound lastReadUrl}}">{{{unbound fancy_title}}}</a>
<a class='title' href="{{unbound lastReadUrl}}">{{{unbound title}}}</a>
{{#if unread}}
<a href="{{unbound lastReadUrl}}" class='badge unread badge-notification' title='{{i18n topic.unread_posts count="unread"}}'>{{unbound unread}}</a>
{{/if}}
@ -19,7 +19,7 @@
<td class='num'>
{{#if like_count}}
<a href='{{url}}{{#if has_best_of}}/best_of{{/if}}'>{{like_count}} <i class='icon-heart'></i></a>
<a href='{{url}}{{#if has_best_of}}?filter=best_of{{/if}}'>{{like_count}} <i class='icon-heart'></i></a>
{{/if}}
</td>

View file

@ -1,140 +1,133 @@
{{#if content}}
{{#if loaded}}
{{#if postStream.loaded}}
{{#if view.firstPostLoaded}}
{{#if postStream.firstPostLoaded}}
<div id='topic-title'>
<div class='container'>
<div class='inner'>
{{#if view.showFavoriteButton}}
<a {{bindAttr class=":star view.topic.starred:starred"}} {{action toggleStar}} href='#' {{bindAttr title="favoriteTooltip"}}></a>
{{#if showFavoriteButton}}
<a {{bindAttr class=":star starred:starred"}} {{action toggleStar}} href='#' {{bindAttr title="favoriteTooltip"}}></a>
{{/if}}
{{#if view.editingTopic}}
<input id='edit-title' type='text' {{bindAttr value="view.topic.title"}} autofocus>
{{categoryChooser valueAttribute="name" source=view.topic.categoryName}}
{{#if editingTopic}}
{{textField id='edit-title' value=newTitle}}
{{categoryChooser valueAttribute="id" value=newCategoryId source=category_id}}
<button class='btn btn-primary btn-small' {{action finishedEdit target="view"}}><i class='icon-ok'></i></button>
<button class='btn btn-small' {{action cancelEdit target="view"}}><i class='icon-remove'></i></button>
<button class='btn btn-primary btn-small' {{action finishedEditingTopic}}><i class='icon-ok'></i></button>
<button class='btn btn-small' {{action cancelEditingTopic}}><i class='icon-remove'></i></button>
{{else}}
<h1>
{{#if view.topic.fancy_title}}
{{topicStatus topic=view.topic}}
<a href='{{unbound view.topic.url}}'>{{{view.topic.fancy_title}}}</a>
{{else}}
{{#if view.topic.errorLoading}}
{{view.topic.errorTitle}}
{{else}}
{{i18n topic.loading}}
{{/if}}
{{#if details.loaded}}
{{topicStatus topic=model}}
<a href='{{unbound url}}'>{{{fancy_title}}}</a>
{{/if}}
{{categoryLink category}}
{{#if view.topic.can_edit}}
<a href='#' {{action editTopic target="view"}} class='edit-topic' title='{{i18n edit}}'><i class="icon-pencil"></i></a>
{{#if details.can_edit}}
<a href='#' {{action editTopic}} class='edit-topic' title='{{i18n edit}}'><i class="icon-pencil"></i></a>
{{/if}}
</h1>
{{/if}}
</div>
</div>
</div>
{{/if}}
{{/if}}
<div class="container posts">
<div class="container posts">
{{view Discourse.SelectedPostsView}}
<div class="row">
<section class="topic-area" id='topic' data-topic-id='{{unbound id}}'>
<div class='posts-wrapper'>
<div id='topic-progress-wrapper'>
<nav id='topic-progress' title="{{i18n topic.progress.title}}" {{bindAttr class="hideProgress:hidden"}}>
<button id='jump-top' title="{{i18n topic.progress.jump_top}}" {{action jumpTop}}><i class="icon-circle-arrow-up"></i></button>
<div class='nums'>
<h4 title="{{i18n topic.progress.current}}">{{view.progressPosition}}</h4> <span>{{i18n of_value}}</span> <h4>{{filtered_posts_count}}</h4>
</div>
<button id='jump-bottom' title="{{i18n topic.progress.jump_bottom}}" {{action jumpBottom}}><i class="icon-circle-arrow-down"></i></button>
<div class='bg'>&nbsp;</div>
</nav>
</div>
{{#if loadingAbove}}
<div class='spinner'>{{i18n loading}}</div>
{{/if}}
{{collection itemViewClass="Discourse.PostView" contentBinding="posts" topicViewBinding="view"}}
{{#if loadingBelow}}
<div class='spinner'>{{i18n loading}}</div>
{{/if}}
{{view Discourse.SelectedPostsView}}
<div class="row">
<section class="topic-area" id='topic' data-topic-id='{{unbound id}}'>
<div class='posts-wrapper'>
<div id='topic-progress-wrapper'>
<nav id='topic-progress' title="{{i18n topic.progress.title}}" {{bindAttr class="hideProgress:hidden"}}>
<button id='jump-top' title="{{i18n topic.progress.jump_top}}" {{bindAttr disabled="jumpTopDisabled"}} {{action jumpTop}}><i class="icon-circle-arrow-up"></i></button>
<div class='nums'>
<h4 title="{{i18n topic.progress.current}}">{{progressPosition}}</h4> <span>{{i18n of_value}}</span> <h4>{{postStream.filteredPostsCount}}</h4>
</div>
<button id='jump-bottom' title="{{i18n topic.progress.jump_bottom}}" {{bindAttr disabled="jumpBottomDisabled"}} {{action jumpBottom}}><i class="icon-circle-arrow-down"></i></button>
<div class='bg'>&nbsp;</div>
</nav>
</div>
<div id='topic-bottom'></div>
{{#if loading}}
{{#unless loadingBelow}}
<div class='spinner small'>{{i18n loading}}</div>
{{/unless}}
{{else}}
{{#if view.fullyLoaded}}
{{view Discourse.TopicClosingView topicBinding="model"}}
{{view Discourse.TopicFooterButtonsView topicBinding="model"}}
{{#if suggested_topics.length}}
<div id='suggested-topics'>
<h3>{{i18n suggested_topics.title}}</h3>
<div class='topics'>
<table id="topic-list">
<tr>
<th>
{{i18n topic.title}}
</th>
<th>{{i18n category_title}}</th>
<th class='num'>{{i18n posts}}</th>
<th class='num'>{{i18n likes}}</th>
<th class='num'>{{i18n views}}</th>
<th class='num activity' colspan='2'>{{i18n activity}}</th>
</tr>
{{each suggested_topics itemTagName="tr" itemViewClass="Discourse.SuggestedTopicView"}}
</table>
</div>
<br/>
<h3>{{{view.browseMoreMessage}}}</h3>
</div>
{{/if}}
{{/if}}
{{#if postStream.loadingAbove}}
<div class='spinner'>{{i18n loading}}</div>
{{/if}}
{{#unless postStream.loadingFilter}}
{{collection itemViewClass="Discourse.PostView" contentBinding="postStream.posts" topicViewBinding="view"}}
{{/unless}}
</section>
</div>
{{#if postStream.loadingBelow}}
<div class='spinner'>{{i18n loading}}</div>
{{/if}}
</div>
<div id='topic-bottom'></div>
{{#if postStream.loadingFilter}}
<div class='spinner small'>{{i18n loading}}</div>
{{else}}
{{#if postStream.lastPostLoaded}}
{{view Discourse.TopicClosingView topicBinding="model"}}
{{view Discourse.TopicFooterButtonsView topicBinding="model"}}
{{#if details.suggested_topics.length}}
<div id='suggested-topics'>
<h3>{{i18n suggested_topics.title}}</h3>
<div class='topics'>
<table id="topic-list">
<tr>
<th>
{{i18n topic.title}}
</th>
<th>{{i18n category_title}}</th>
<th class='num'>{{i18n posts}}</th>
<th class='num'>{{i18n likes}}</th>
<th class='num'>{{i18n views}}</th>
<th class='num activity' colspan='2'>{{i18n activity}}</th>
</tr>
{{each details.suggested_topics itemTagName="tr" itemViewClass="Discourse.SuggestedTopicView"}}
</table>
</div>
<br/>
<h3>{{{view.browseMoreMessage}}}</h3>
</div>
{{/if}}
{{/if}}
{{/if}}
</section>
</div>
</div>
{{else}}
{{#if message}}
<div class='container'>
<div class='message'>
<h2>{{message}}</h2>
<p>
{{#linkTo list.latest}}{{i18n topic.back_to_list}}{{/linkTo}}
</div>
</div>
{{else}}
{{#if message}}
<div class='container'>
<div class='message'>
<h2>{{message}}</h2>
<p>
{{#linkTo list.latest}}{{i18n topic.back_to_list}}{{/linkTo}}
</div>
</div>
{{else}}
<div class='container'>
<div class='spinner'>{{i18n loading}}</div>
</div>
{{/if}}
<div class='container'>
<div class='spinner'>{{i18n loading}}</div>
</div>
{{/if}}
{{/if}}
<div id='topic-filter' style='display: none'>
{{filterDesc}}
<a href='#' {{action cancelFilter}}>{{i18n topic.filters.cancel}}</a>
<div id='topic-filter' {{bindAttr class="postStream.hasNoFilters:hidden"}}>
{{postStream.filterDesc}}
<a href='#' {{action cancelFilter target="postStream"}}>{{i18n topic.filters.cancel}}</a>
</div>
{{render share}}

View file

@ -7,7 +7,7 @@
<button {{action toggleMultiSelect}} class='btn btn-admin'><i class='icon-tasks'></i> {{i18n topic.actions.multi_select}}</button>
</li>
{{#if can_delete}}
{{#if details.can_delete}}
<li>
<button {{action deleteTopic}} class='btn btn-admin btn-danger'><i class='icon-trash'></i> {{i18n topic.actions.delete}}</button>
</li>

View file

@ -1,8 +1,8 @@
<h3><i class='icon icon-bullhorn'></i> {{i18n best_of.title}}</h3>
{{#if bestOf}}
{{#if postStream.bestOf}}
<p>{{{i18n best_of.enabled_description}}}</p>
<button class='btn' {{action cancelFilter}}>{{i18n best_of.disable}}</button>
<button class='btn' {{action toggleBestOf target="postStream"}}>{{i18n best_of.disable}}</button>
{{else}}
<p>{{{i18n best_of.description count="posts_count"}}}</p>
<button class='btn' {{action enableBestOf}}>{{i18n best_of.enable}}</button>
<button class='btn' {{action toggleBestOf target="postStream"}}>{{i18n best_of.enable}}</button>
{{/if}}

View file

@ -17,14 +17,14 @@
<li>
<a {{bindAttr href="url"}}>
<h4>{{i18n created}}</h4>
{{avatar created_by imageSize="tiny"}}
{{avatar details.created_by imageSize="tiny"}}
{{date created_at}}
</a>
</li>
<li>
<a {{bindAttr href="lastPostUrl"}}>
<h4>{{i18n last_post}}</h4>
{{avatar last_poster imageSize="tiny"}}
{{avatar details.last_poster imageSize="tiny"}}
{{date last_posted_at}}
</a>
</li>
@ -38,11 +38,11 @@
</li>
<li>
<h4>{{i18n links}}</h4>
{{number links.length}}
{{number details.links.length}}
</li>
{{#if fewParticipants}}
{{#if details.fewParticipants}}
<li class='avatars'>
{{#each fewParticipants}}{{participant participant=this}}{{/each}}
{{#each details.fewParticipants}}{{participant participant=this}}{{/each}}
</li>
{{/if}}
</ul>
@ -53,12 +53,12 @@
<ul class="clearfix">
<li>
<h4>{{i18n created}}</h4>
{{avatar created_by imageSize="tiny"}}
{{avatar details.created_by imageSize="tiny"}}
<a {{bindAttr href="url"}}>{{date created_at}}</a>
</li>
<li>
<h4>{{i18n last_post}}</h4>
{{avatar last_poster imageSize="tiny"}}
{{avatar details.last_poster imageSize="tiny"}}
<a {{bindAttr href="lastPostUrl"}}>{{date last_posted_at}}</a>
</li>
<li>
@ -72,9 +72,9 @@
</ul>
</section>
{{#if participants}}
{{#if details.participants}}
<section class='avatars clearfix'>
{{#each participants}}{{participant participant=this}}{{/each}}
{{#each details.participants}}{{participant participant=this}}{{/each}}
</section>
{{/if}}
@ -92,7 +92,7 @@
{{#if view.parentView.showAllLinksControls}}
<div class='link-summary'>
<a href='#' {{action showAllLinks target="view.parentView"}}>{{i18n topic_summary.links_shown totalLinks="links.length"}}</a>
<a href='#' {{action showAllLinks target="view.parentView"}}>{{i18n topic_summary.links_shown totalLinks="details.links.length"}}</a>
</div>
{{/if}}

View file

@ -1,11 +1,11 @@
<h3><i class='icon icon-envelope-alt'></i> {{i18n private_message_info.title}}</h3>
<div class='participants clearfix'>
{{#each allowed_groups}}
{{#each details.allowed_groups}}
<div class='user group'>
#{{unbound name}}
</div>
{{/each}}
{{#each allowed_users}}
{{#each details.allowed_users}}
<div class='user'>
<a href='/users/{{lower username}}'>
{{avatar this imageSize="small"}}
@ -13,13 +13,13 @@
<a href='/users/{{lower username}}'>
{{unbound username}}
</a>
{{#if controller.model.can_remove_allowed_users}}
{{#if controller.model.details.can_remove_allowed_users}}
<a class='remove-invited' {{action removeAllowedUser username}}><i class="icon-remove"></i></a>
{{/if}}
</div>
{{/each}}
</div>
{{#if can_invite_to}}
{{#if details.can_invite_to}}
<div class='controls'>
<button class='btn' {{action showPrivateInvite}}>{{i18n private_message_info.invite}}</button>
</div>

View file

@ -8,7 +8,7 @@
**/
Discourse.NotificationsButton = Discourse.DropdownButtonView.extend({
title: Em.String.i18n('topic.notifications.title'),
longDescriptionBinding: 'topic.notificationReasonText',
longDescriptionBinding: 'topic.details.notificationReasonText',
dropDownContent: [
[Discourse.Topic.NotificationLevel.WATCHING, 'topic.notifications.watching'],
@ -19,7 +19,7 @@ Discourse.NotificationsButton = Discourse.DropdownButtonView.extend({
text: function() {
var key = (function() {
switch (this.get('topic.notification_level')) {
switch (this.get('topic.details.notification_level')) {
case Discourse.Topic.NotificationLevel.WATCHING: return 'watching';
case Discourse.Topic.NotificationLevel.TRACKING: return 'tracking';
case Discourse.Topic.NotificationLevel.REGULAR: return 'regular';
@ -36,10 +36,10 @@ Discourse.NotificationsButton = Discourse.DropdownButtonView.extend({
}
})();
return icon + (Ember.String.i18n("topic.notifications." + key + ".title")) + "<span class='caret'></span>";
}.property('topic.notification_level'),
}.property('topic.details.notification_level'),
clicked: function(id) {
return this.get('topic').updateNotifications(id);
return this.get('topic.details').updateNotifications(id);
}
});

View file

@ -10,7 +10,7 @@ Discourse.ReplyButton = Discourse.ButtonView.extend({
classNames: ['btn', 'btn-primary', 'create'],
attributeBindings: ['disabled'],
helpKey: 'topic.reply.help',
disabled: Em.computed.not('controller.content.can_create_post'),
disabled: Em.computed.not('controller.model.details.can_create_post'),
text: function() {
var archetypeCapitalized = this.get('controller.content.archetype').capitalize();

View file

@ -9,9 +9,9 @@
Discourse.ParticipantView = Discourse.View.extend({
templateName: 'participant',
toggled: (function() {
return this.get('controller.userFilters').contains(this.get('participant.username'));
}).property('controller.userFilters.[]')
toggled: function() {
return this.get('controller.postStream.userFilters').contains(this.get('participant.username'));
}.property('controller.postStream.userFilters.[]')
});

View file

@ -60,7 +60,7 @@ Discourse.PostMenuView = Discourse.View.extend({
// Delete button
renderDelete: function(post, buffer) {
if (post.get('post_number') === 1 && this.get('controller.content.can_delete')) {
if (post.get('post_number') === 1 && this.get('controller.model.details.can_delete')) {
buffer.push("<button title=\"" +
(Em.String.i18n("topic.actions.delete")) +
"\" data-action=\"deleteTopic\" class='delete'><i class=\"icon-trash\"></i></button>");
@ -138,7 +138,7 @@ Discourse.PostMenuView = Discourse.View.extend({
// Reply button
renderReply: function(post, buffer) {
if (!this.get('controller.content.can_create_post')) return;
if (!this.get('controller.model.details.can_create_post')) return;
buffer.push("<button title=\"" +
(Em.String.i18n("post.controls.reply")) +
"\" class='create' data-action=\"reply\"><i class='icon-reply'></i>" +

View file

@ -98,9 +98,9 @@ Discourse.PostView = Discourse.View.extend({
updateQuoteElements: function($aside, desc) {
var navLink = "";
var quoteTitle = Em.String.i18n("post.follow_quote");
var postNumber;
var postNumber = $aside.data('post');
if (postNumber = $aside.data('post')) {
if (postNumber) {
// If we have a topic reference
var topicId, topic;
@ -209,21 +209,6 @@ Discourse.PostView = Discourse.View.extend({
didInsertElement: function() {
var $post = this.$();
var post = this.get('post');
var postNumber = post.get('scrollToAfterInsert');
// Do we want to scroll to this post now that we've inserted it?
if (postNumber) {
Discourse.TopicView.scrollTo(this.get('post.topic_id'), postNumber);
if (postNumber === post.get('post_number')) {
var $contents = $('.topic-body .contents', $post);
var originalCol = $contents.css('backgroundColor');
$contents.css({
backgroundColor: "#ffffcc"
}).animate({
backgroundColor: originalCol
}, 2500);
}
}
this.showLinkCounts();
// Track this post
@ -233,21 +218,9 @@ Discourse.PostView = Discourse.View.extend({
Discourse.SyntaxHighlighting.apply($post);
Discourse.Lightbox.apply($post);
// If we're scrolling upwards, adjust the scroll position accordingly
var scrollTo = this.get('post.scrollTo');
if (scrollTo) {
$('body').scrollTop(($(document).height() - scrollTo.height) + scrollTo.top);
$('section.divider').addClass('fade');
}
// Find all the quotes
this.insertQuoteControls();
$post.addClass('ready');
// be sure that eyeline tracked it
var controller = this.get('controller');
if (controller && controller.postRendered) {
controller.postRendered(post);
}
}
});

View file

@ -13,12 +13,12 @@ Discourse.TopicClosingView = Discourse.View.extend({
contentChanged: function() {
this.rerender();
}.observes('topic.auto_close_at'),
}.observes('topic.details.auto_close_at'),
render: function(buffer) {
if (!this.present('topic.auto_close_at')) return;
if (!this.present('topic.details.auto_close_at')) return;
var autoCloseAt = moment(this.get('topic.auto_close_at'));
var autoCloseAt = moment(this.get('topic.details.auto_close_at'));
if (autoCloseAt < new Date()) return;

View file

@ -22,7 +22,7 @@ Discourse.TopicFooterButtonsView = Discourse.ContainerView.extend({
if (!topic.get('isPrivateMessage')) {
// We hide some controls from private messages
if (this.get('topic.can_invite_to')) {
if (this.get('topic.details.can_invite_to')) {
this.attachViewClass(Discourse.InviteReplyButton);
}
this.attachViewClass(Discourse.FavoriteButton);

View file

@ -7,25 +7,24 @@
@module Discourse
**/
Discourse.TopicSummaryView = Discourse.ContainerView.extend({
topicBinding: 'controller.content',
classNameBindings: ['hidden', ':topic-summary'],
LINKS_SHOWN: 5,
allLinksShown: false,
topic: Em.computed.alias('controller.model'),
showAllLinksControls: function() {
if (this.blank('topic.links')) return false;
if (this.get('allLinksShown')) return false;
if (this.get('topic.links.length') <= this.LINKS_SHOWN) return false;
if ((this.get('topic.details.links.length') || 0) <= Discourse.TopicSummaryView.LINKS_SHOWN) return false;
return true;
}.property('allLinksShown', 'topic.links'),
}.property('allLinksShown', 'topic.details.links'),
infoLinks: function() {
if (this.blank('topic.links')) return [];
if (this.blank('topic.details.links')) return [];
var allLinks = this.get('topic.links');
var allLinks = this.get('topic.details.links');
if (this.get('allLinksShown')) return allLinks;
return allLinks.slice(0, this.LINKS_SHOWN);
}.property('topic.links', 'allLinksShown'),
return allLinks.slice(0, Discourse.TopicSummaryView.LINKS_SHOWN);
}.property('topic.details.links', 'allLinksShown'),
newPostCreated: function() {
this.rerender();
@ -77,4 +76,6 @@ Discourse.TopicSummaryView = Discourse.ContainerView.extend({
}
});
Discourse.TopicSummaryView.reopenClass({
LINKS_SHOWN: 5
});

View file

@ -12,23 +12,24 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
topicBinding: 'controller.content',
userFiltersBinding: 'controller.userFilters',
classNameBindings: ['controller.multiSelect:multi-select', 'topic.archetype', 'topic.category.secure:secure_category'],
progressPosition: 1,
menuVisible: true,
SHORT_POST: 1200,
postStream: Em.computed.alias('controller.postStream'),
// Update the progress bar using sweet animations
updateBar: function() {
var $topicProgress, bg, currentWidth, progressWidth, ratio, totalWidth;
if (!this.get('topic.loaded')) return;
$topicProgress = $('#topic-progress');
if (!this.get('postStream.loaded')) return;
var $topicProgress = $('#topic-progress');
if (!$topicProgress.length) return;
ratio = this.get('progressPosition') / this.get('topic.filtered_posts_count');
totalWidth = $topicProgress.width();
progressWidth = ratio * totalWidth;
bg = $topicProgress.find('.bg');
var ratio = this.get('controller.progressPosition') / this.get('postStream.filteredPostsCount');
var totalWidth = $topicProgress.width();
var progressWidth = ratio * totalWidth;
var bg = $topicProgress.find('.bg');
bg.stop(true, true);
currentWidth = bg.width();
var currentWidth = bg.width();
if (currentWidth === totalWidth) {
bg.width(currentWidth - 1);
@ -40,9 +41,8 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
bg.css("border-right-width", "1px");
}
// Disable animation for now so it performs better
bg.width(progressWidth);
}.observes('progressPosition', 'topic.filtered_posts_count', 'topic.loaded'),
}.observes('controller.progressPosition', 'postStream.filteredPostsCount', 'topic.loaded'),
updateTitle: function() {
var title = this.get('topic.title');
@ -60,28 +60,9 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
}
var postUrl = topic.get('url');
if (current > 1) {
postUrl += "/" + current;
} else {
if (this.get('controller.bestOf')) {
postUrl += "/best_of";
}
}
if (current > 1) { postUrl += "/" + current; }
Discourse.URL.replaceState(postUrl);
// Show appropriate jump tools
if (current === 1) {
$('#jump-top').attr('disabled', true);
} else {
$('#jump-top').attr('disabled', false);
}
if (current === this.get('topic.highest_post_number')) {
$('#jump-bottom').attr('disabled', true);
} else {
$('#jump-bottom').attr('disabled', false);
}
}.observes('controller.currentPost', 'controller.bestOf', 'topic.highest_post_number'),
}.observes('controller.currentPost', 'highest_post_number'),
composeChanged: function() {
var composerController = Discourse.get('router.composerController');
@ -98,8 +79,6 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
// Unbind link tracking
this.$().off('mouseup.discourse-redirect', '.cooked a, a.track-link');
this.get('controller').set('onPostRendered', null);
this.resetExamineDockCache();
// this happens after route exit, stuff could have trickled in
@ -110,25 +89,20 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
this.bindScrolling({debounce: 0});
var topicView = this;
$(window).bind('resize.discourse-on-scroll', function() { topicView.updatePosition(false); });
var controller = this.get('controller');
controller.set('onPostRendered', function(){
topicView.postsRendered.apply(topicView);
});
$(window).bind('resize.discourse-on-scroll', function() { topicView.updatePosition(); });
this.$().on('mouseup.discourse-redirect', '.cooked a, a.track-link', function(e) {
return Discourse.ClickTrack.trackClick(e);
});
this.updatePosition(true);
this.updatePosition();
},
debounceLoadSuggested: Discourse.debounce(function(){
if (this.get('isDestroyed') || this.get('isDestroying')) { return; }
var incoming = this.get('topicTrackingState.newIncoming');
var suggested = this.get('topic.suggested_topics');
var suggested = this.get('topic.details.suggested_topics');
var topicId = this.get('topic.id');
if(suggested) {
@ -155,11 +129,6 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
this.debounceLoadSuggested();
}.observes('topicTrackingState.incomingCount'),
// Triggered whenever any posts are rendered, debounced to save over calling
postsRendered: Discourse.debounce(function() {
this.updatePosition(false);
}, 50),
resetRead: function(e) {
Discourse.ScreenTrack.instance().reset();
this.get('controller').unsubscribe();
@ -192,8 +161,8 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
if (post) {
var postNumber = post.get('post_number');
if (postNumber > (this.get('topic.last_read_post_number') || 0)) {
this.set('topic.last_read_post_number', postNumber);
if (postNumber > (this.get('last_read_post_number') || 0)) {
this.set('last_read_post_number', postNumber);
}
if (!post.get('read')) {
post.set('read', true);
@ -202,174 +171,6 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
}
},
observeFirstPostLoaded: (function() {
var loaded, old, posts;
posts = this.get('topic.posts');
// TODO topic.posts stores non ember objects in it for a period of time, this is bad
loaded = posts && posts[0] && posts[0].post_number === 1;
// I avoided a computed property cause I did not want to set it, over and over again
old = this.get('firstPostLoaded');
if (loaded) {
if (old !== true) {
this.set('firstPostLoaded', true);
}
} else {
if (old !== false) {
this.set('firstPostLoaded', false);
}
}
}).observes('topic.posts.@each'),
// Load previous posts if there are some
prevPage: function($post) {
var postView = Ember.View.views[$post.prop('id')];
if (!postView) return;
var post = postView.get('post');
if (!post) return;
// We don't load upwards from the first page
if (post.post_number === 1) return;
// double check
if (this.topic && this.topic.posts && this.topic.posts.length > 0 && this.topic.posts[0].post_number !== post.post_number) return;
// half mutex
if (this.get('controller.loading')) return;
this.set('controller.loading', true);
this.set('controller.loadingAbove', true);
var opts = $.extend({ postsBefore: post.get('post_number') }, this.get('controller.postFilters'));
var topicView = this;
return Discourse.Topic.find(this.get('topic.id'), opts).then(function(result) {
var lastPostNum, posts;
posts = topicView.get('topic.posts');
// Add a scrollTo record to the last post inserted to the DOM
lastPostNum = result.posts[0].post_number;
_.each(result.posts,function(post) {
var newPost;
newPost = Discourse.Post.create(post, topicView.get('topic'));
if (post.post_number === lastPostNum) {
newPost.set('scrollTo', {
top: $(window).scrollTop(),
height: $(document).height()
});
}
return posts.unshiftObject(newPost);
});
topicView.set('controller.loading', false);
return topicView.set('controller.loadingAbove', false);
});
},
fullyLoaded: (function() {
return this.get('controller.seenBottom') || this.get('topic.at_bottom');
}).property('topic.at_bottom', 'controller.seenBottom'),
// Load new posts if there are some
nextPage: function($post) {
if (this.get('controller.loading') || this.get('controller.seenBottom')) return;
return this.loadMore(this.getPost($post));
},
postCountChanged: function() {
this.set('controller.seenBottom', false);
}.observes('topic.highest_post_number'),
loadMore: function(post) {
if (!post) return;
if (this.get('controller.loading')) return;
// Don't load if we know we're at the bottom
if (this.get('topic.highest_post_number') === post.get('post_number')) return;
if (this.get('controller.seenBottom')) return;
// Don't double load ever
if (this.topic.posts[this.topic.posts.length-1].post_number !== post.post_number) return;
this.set('controller.loadingBelow', true);
this.set('controller.loading', true);
var opts = $.extend({ postsAfter: post.get('post_number') }, this.get('controller.postFilters'));
var topicView = this;
var topic = this.get('controller.content');
return Discourse.Topic.find(topic.get('id'), opts).then(function(result) {
if (result.at_bottom || result.posts.length === 0) {
topicView.set('controller.seenBottom', 'true');
}
topic.pushPosts(_.map(result.posts,function(p) {
return Discourse.Post.create(p, topic);
}));
if (result.suggested_topics) {
var suggested = Em.A();
_.each(result.suggested_topics,function(topic) {
suggested.pushObject(Discourse.Topic.create(topic));
});
topicView.set('topic.suggested_topics', suggested);
}
topicView.set('controller.loadingBelow', false);
return topicView.set('controller.loading', false);
});
},
cancelEdit: function() {
// close editing mode
this.set('editingTopic', false);
},
finishedEdit: function() {
// TODO: This should be in a controller and use proper text fields
var topicView = this;
if (this.get('editingTopic')) {
var topic = this.get('topic');
// retrieve the title from the text field
var newTitle = $('#edit-title').val();
// retrieve the category from the combox box
var newCategoryName = $('#topic-title select option:selected').val();
// manually update the titles & category
topic.setProperties({
title: newTitle,
fancy_title: newTitle,
categoryName: newCategoryName
});
// 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) {
topicView.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
topicView.set('editingTopic', false);
}
},
editTopic: function() {
if (!this.get('topic.can_edit')) return false;
// enable editing mode
this.set('editingTopic', true);
return false;
},
showFavoriteButton: function() {
return Discourse.User.current() && !this.get('topic.isPrivateMessage');
}.property('topic.isPrivateMessage'),
resetExamineDockCache: function() {
this.docAt = null;
this.dockedTitle = false;
@ -380,22 +181,23 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
if (!postView) return;
var post = postView.get('post');
if (!post) return;
this.set('progressPosition', post.get('index'));
this.set('controller.progressPosition', this.get('postStream').indexOf(post) + 1);
},
nonUrgentPositionUpdate: Discourse.debounce(function(opts) {
throttledPositionUpdate: Discourse.debounce(function() {
Discourse.ScreenTrack.instance().scrolled();
var model = this.get('controller.model');
if (model) {
this.set('controller.currentPost', opts.currentPost);
if (model && this.get('nextPositionUpdate')) {
this.set('controller.currentPost', this.get('nextPositionUpdate'));
}
},500),
scrolled: function(){
this.updatePosition(true);
this.updatePosition();
},
updatePosition: function(userActive) {
updatePosition: function() {
var topic = this.get('controller.model');
var rows = $('.topic-post.ready');
if (!rows || rows.length === 0) { return; }
@ -404,16 +206,23 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
var info = Discourse.Eyeline.analyze(rows);
if(!info) { return; }
// top on screen
// are we scrolling upwards?
if(info.top === 0 || info.onScreen[0] === 0 || info.bottom === 0) {
this.prevPage($(rows[0]));
var $body = $('body');
var $elem = $(rows[0]);
var distToElement = $body.scrollTop() - $elem.position().top;
this.get('postStream').prependMore().then(function() {
Em.run.next(function () {
$('html, body').scrollTop($elem.position().top + distToElement);
});
});
}
// bottom of screen
// are we scrolling down?
var currentPost;
if(info.bottom === rows.length-1) {
currentPost = this.postSeen($(rows[info.bottom]));
this.nextPage($(rows[info.bottom]));
this.get('postStream').appendMore();
}
// update dock
@ -433,16 +242,14 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
}
if (currentForPositionUpdate) {
this.nonUrgentPositionUpdate({
userActive: userActive,
currentPost: currentPost || currentForPositionUpdate
});
this.set('nextPositionUpdate', currentPost || currentForPositionUpdate);
this.throttledPositionUpdate();
} else {
console.error("can't update position ");
}
var offset = window.pageYOffset || $('html').scrollTop();
var firstLoaded = this.get('firstPostLoaded');
var firstLoaded = topic.get('postStream.firstPostLoaded');
if (!this.docAt) {
var title = $('#topic-title');
if (title && title.length === 1) {
@ -475,18 +282,17 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
}
},
topicTrackingState: function(){
topicTrackingState: function() {
return Discourse.TopicTrackingState.current();
}.property(),
browseMoreMessage: function() {
var category, opts;
opts = {
var opts = {
latestLink: "<a href=\"/\">" + (Em.String.i18n("topic.view_latest_topics")) + "</a>"
};
category = this.get('controller.content.category');
var category = this.get('controller.content.category');
if (category) {
opts.catLink = Discourse.Utilities.categoryLink(category);
} else {
@ -522,27 +328,32 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
Discourse.TopicView.reopenClass({
// Scroll to a given post, if in the DOM. Returns whether it was in the DOM or not.
scrollTo: function(topicId, postNumber, callback) {
// Make sure we're looking at the topic we want to scroll to
var existing, header, title, expectedOffset;
if (parseInt(topicId, 10) !== parseInt($('#topic').data('topic-id'), 10)) return false;
existing = $("#post_" + postNumber);
if (existing.length) {
if (postNumber === 1) {
$('html, body').scrollTop(0);
} else {
header = $('header');
title = $('#topic-title');
expectedOffset = title.height() - header.find('.contents').height();
jumpToPost: function(topicId, postNumber) {
Em.run.scheduleOnce('afterRender', function() {
if (expectedOffset < 0) {
expectedOffset = 0;
// Make sure we're looking at the topic we want to scroll to
if (topicId !== parseInt($('#topic').data('topic-id'), 10)) { return false; }
var $post = $("#post_" + postNumber);
if ($post.length) {
if (postNumber === 1) {
$('html, body').scrollTop(0);
} else {
var header = $('header');
var title = $('#topic-title');
var expectedOffset = title.height() - header.find('.contents').height();
if (expectedOffset < 0) {
expectedOffset = 0;
}
$('html, body').scrollTop($post.offset().top - (header.outerHeight(true) + expectedOffset));
var $contents = $('.topic-body .contents', $post);
var originalCol = $contents.css('backgroundColor');
$contents.css({ backgroundColor: "#ffffcc" }).animate({ backgroundColor: originalCol }, 2500);
}
$('html, body').scrollTop(existing.offset().top - (header.outerHeight(true) + expectedOffset));
}
return true;
}
return false;
});
}
});

View file

@ -25,12 +25,11 @@ class TopicsController < ApplicationController
caches_action :avatar, cache_path: Proc.new {|c| "#{c.params[:post_number]}-#{c.params[:topic_id]}" }
def show
# We'd like to migrate the wordpress feed to another url. This keeps up backwards compatibility with
# existing installs.
return wordpress if params[:best].present?
opts = params.slice(:username_filters, :best_of, :page, :post_number, :posts_before, :posts_after)
opts = params.slice(:username_filters, :filter, :page, :post_number)
begin
@topic_view = TopicView.new(params[:id] || params[:topic_id], current_user, opts)
rescue Discourse::NotFound
@ -67,7 +66,15 @@ class TopicsController < ApplicationController
wordpress_serializer = TopicViewWordpressSerializer.new(@topic_view, scope: guardian, root: false)
render_json_dump(wordpress_serializer)
end
end
def posts
params.require(:topic_id)
params.require(:post_ids)
@topic_view = TopicView.new(params[:topic_id], current_user, post_ids: params[:post_ids])
render_json_dump(TopicViewPostsSerializer.new(@topic_view, scope: guardian, root: false))
end
def destroy_timings

View file

@ -74,7 +74,7 @@ class SiteSetting < ActiveRecord::Base
setting(:create_thumbnails, false)
client_setting(:category_featured_topics, 6)
setting(:topics_per_page, 30)
setting(:posts_per_page, 20)
client_setting(:posts_per_page, 20)
setting(:invite_expiry_days, 14)
setting(:active_user_rate_limit_secs, 60)
setting(:previous_visit_timeout_hours, 1)

View file

@ -0,0 +1,32 @@
module PostStreamSerializerMixin
def self.included(klass)
klass.attributes :post_stream
end
def post_stream
{ posts: posts,
stream: object.filtered_post_ids }
end
def posts
return @posts if @posts.present?
@posts = []
@highest_number_in_posts = 0
if object.posts.present?
object.posts.each_with_index do |p, idx|
if p.user
@highest_number_in_posts = p.post_number if p.post_number > @highest_number_in_posts
ps = PostSerializer.new(p, scope: scope, root: false)
ps.topic_slug = object.topic.slug
ps.topic_view = object
p.topic = object.topic
@posts << ps.as_json
end
end
end
@posts
end
end

View file

@ -0,0 +1,10 @@
class TopicViewPostsSerializer < ApplicationSerializer
include PostStreamSerializerMixin
attributes :id
def id
object.topic.id
end
end

View file

@ -1,6 +1,7 @@
require_dependency 'pinned_check'
class TopicViewSerializer < ApplicationSerializer
include PostStreamSerializerMixin
# These attributes will be delegated to the topic
def self.topic_attributes
@ -15,76 +16,88 @@ class TopicViewSerializer < ApplicationSerializer
:visible,
:closed,
:archived,
:moderator_posts_count,
:has_best_of,
:archetype,
:slug,
:auto_close_at]
:category_id]
end
def self.guardian_attributes
[:can_moderate, :can_edit, :can_delete, :can_invite_to, :can_move_posts, :can_remove_allowed_users]
end
attributes *topic_attributes
attributes *guardian_attributes
attributes :draft,
:draft_key,
:draft_sequence,
:post_action_visibility,
:voted_in_topic,
:can_create_post,
:can_reply_as_new_topic,
:categoryName,
:starred,
:last_read_post_number,
:posted,
:notification_level,
:notifications_reason_id,
:posts,
:at_bottom,
:highest_post_number,
:pinned,
:filtered_posts_count
has_one :created_by, serializer: BasicUserSerializer, embed: :objects
has_one :last_poster, serializer: BasicUserSerializer, embed: :objects
has_many :allowed_users, serializer: BasicUserSerializer, embed: :objects
has_many :allowed_groups, serializer: BasicGroupSerializer, embed: :objects
has_many :links, serializer: TopicLinkSerializer, embed: :objects
has_many :participants, serializer: TopicPostCountSerializer, embed: :objects
has_many :suggested_topics, serializer: SuggestedTopicSerializer, embed: :objects
:details,
:highest_post_number,
:last_read_post_number
# Define a delegator for each attribute of the topic we want
attributes *topic_attributes
topic_attributes.each do |ta|
class_eval %{def #{ta}
object.topic.#{ta}
end}
end
# Define the guardian attributes
guardian_attributes.each do |ga|
class_eval %{
def #{ga}
true
end
def include_#{ga}?
scope.#{ga}?(object.topic)
end
# TODO: Split off into proper object / serializer
def details
result = {
auto_close_at: object.topic.auto_close_at,
created_by: BasicUserSerializer.new(object.topic.user, scope: scope, root: false),
last_poster: BasicUserSerializer.new(object.topic.last_poster, scope: scope, root: false)
}
if object.topic.allowed_users.present?
result[:allowed_users] = object.topic.allowed_users.map do |user|
BasicUserSerializer.new(user, scope: scope, root: false)
end
end
if object.topic.allowed_groups.present?
result[:allowed_groups] = object.topic.allowed_groups.map do |ag|
BasicGroupSerializer.new(ag, scope: scope, root: false)
end
end
if object.post_counts_by_user.present?
result[:participants] = object.post_counts_by_user.map do |pc|
TopicPostCountSerializer.new({user: object.participants[pc[0]], post_count: pc[1]}, scope: scope, root: false)
end
end
if object.suggested_topics.try(:topics).present?
result[:suggested_topics] = object.suggested_topics.topics.map do |user|
SuggestedTopicSerializer.new(user, scope: scope, root: false)
end
end
if object.links.present?
result[:links] = object.links.map do |user|
TopicLinkSerializer.new(user, scope: scope, root: false)
end
end
if has_topic_user?
result[:notification_level] = object.topic_user.notification_level
result[:notifications_reason_id] = object.topic_user.notifications_reason_id
end
result[:can_move_posts] = true if scope.can_move_posts?(object.topic)
result[:can_edit] = true if scope.can_edit?(object.topic)
result[:can_delete] = true if scope.can_delete?(object.topic)
result[:can_remove_allowed_users] = true if scope.can_remove_allowed_users?(object.topic)
result[:can_invite_to] = true if scope.can_invite_to?(object.topic)
result[:can_create_post] = true if scope.can_create?(Post, object.topic)
result[:can_reply_as_new_topic] = true if scope.can_reply_as_new_topic?(object.topic)
result
end
def draft
object.draft
end
def include_allowed_users?
object.topic.private_message?
end
def draft_key
object.draft_key
end
@ -93,46 +106,6 @@ class TopicViewSerializer < ApplicationSerializer
object.draft_sequence
end
def post_action_visibility
object.post_action_visibility
end
def include_post_action_visibility?
object.post_action_visibility.present?
end
def filtered_posts_count
object.filtered_posts_count
end
def voted_in_topic
object.voted_in_topic?
end
def can_reply_as_new_topic
true
end
def include_can_reply_as_new_topic?
scope.can_reply_as_new_topic?(object.topic)
end
def can_create_post
true
end
def include_can_create_post?
scope.can_create?(Post, object.topic)
end
def categoryName
object.topic.category.name
end
def include_categoryName?
object.topic.category.present?
end
# Topic user stuff
def has_topic_user?
object.topic_user.present?
@ -143,6 +116,10 @@ class TopicViewSerializer < ApplicationSerializer
end
alias_method :include_starred?, :has_topic_user?
def highest_post_number
object.highest_post_number
end
def last_read_post_number
object.topic_user.last_read_post_number
end
@ -153,90 +130,9 @@ class TopicViewSerializer < ApplicationSerializer
end
alias_method :include_posted?, :has_topic_user?
def notification_level
object.topic_user.notification_level
end
alias_method :include_notification_level?, :has_topic_user?
def notifications_reason_id
object.topic_user.notifications_reason_id
end
alias_method :include_notifications_reason_id?, :has_topic_user?
def created_by
object.topic.user
end
def last_poster
object.topic.last_poster
end
def allowed_users
object.topic.allowed_users
end
def allowed_groups
object.topic.allowed_groups
end
def include_links?
object.links.present?
end
def participants
object.post_counts_by_user.collect {|tuple| {user: object.participants[tuple.first], post_count: tuple[1]}}
end
def include_participants?
object.initial_load? && object.post_counts_by_user.present?
end
def suggested_topics
object.suggested_topics.topics
end
def include_suggested_topics?
at_bottom && object.suggested_topics.present?
end
# Whether we're at the bottom of a topic (last page)
def at_bottom
posts.present? && (@highest_number_in_posts == object.highest_post_number)
end
def highest_post_number
object.highest_post_number
end
def pinned
PinnedCheck.new(object.topic, object.topic_user).pinned?
end
def posts
return @posts if @posts.present?
@posts = []
@highest_number_in_posts = 0
if object.posts.present?
object.posts.each_with_index do |p, idx|
if p.user
@highest_number_in_posts = p.post_number if p.post_number > @highest_number_in_posts
ps = PostSerializer.new(p, scope: scope, root: false)
ps.topic_slug = object.topic.slug
ps.topic_view = object
p.topic = object.topic
post_json = ps.as_json
if object.index_reverse
post_json[:index] = object.index_offset - idx
else
post_json[:index] = object.index_offset + idx + 1
end
@posts << post_json
end
end
end
@posts
end
end

View file

@ -18,7 +18,7 @@ class TopicViewWordpressSerializer < ApplicationSerializer
end
def filtered_posts_count
object.filtered_posts_count
object.filtered_post_ids.size
end
def participants

View file

@ -92,6 +92,7 @@ predef:
- find
- resolvingPromise
- sinon
- controllerFor
browser: true # true if the standard browser globals should be predefined
rhino: false # true if the Rhino environment globals should be predefined

View file

@ -216,6 +216,7 @@ Discourse::Application.routes.draw do
get 't/:slug/:topic_id.rss' => 'topics#feed', format: :rss, constraints: {topic_id: /\d+/}
get 't/:slug/:topic_id' => 'topics#show', constraints: {topic_id: /\d+/}
get 't/:slug/:topic_id/:post_number' => 'topics#show', constraints: {topic_id: /\d+/, post_number: /\d+/}
get 't/:topic_id/posts' => 'topics#posts', constraints: {topic_id: /\d+/}
post 't/:topic_id/timings' => 'topics#timings', constraints: {topic_id: /\d+/}
post 't/:topic_id/invite' => 'topics#invite', constraints: {topic_id: /\d+/}
post 't/:topic_id/move-posts' => 'topics#move_posts', constraints: {topic_id: /\d+/}

View file

@ -4,7 +4,7 @@ require_dependency 'summarize'
class TopicView
attr_reader :topic, :posts, :index_offset, :index_reverse, :guardian
attr_reader :topic, :posts, :guardian, :filtered_posts
attr_accessor :draft, :draft_key, :draft_sequence
def initialize(topic_id, user=nil, options={})
@ -20,13 +20,14 @@ class TopicView
end
guardian.ensure_can_see!(@topic)
@post_number, @page = options[:post_number], options[:page]
@limit = options[:limit] || SiteSetting.posts_per_page;
@filtered_posts = @topic.posts
@filtered_posts = @filtered_posts.with_deleted if user.try(:staff?)
@filtered_posts = @filtered_posts.best_of if options[:best_of].present?
@filtered_posts = @filtered_posts.best_of if options[:filter] == 'best_of'
@filtered_posts = @filtered_posts.where('posts.post_type <> ?', Post.types[:moderator_action]) if options[:best].present?
if options[:username_filters].present?
@ -78,10 +79,6 @@ class TopicView
@topic.title
end
def filtered_posts_count
@filtered_posts_count ||= @filtered_posts.count
end
def summary
return nil if posts.blank?
Summarize.new(posts.first.cooked).summary
@ -94,11 +91,8 @@ class TopicView
def filter_posts(opts = {})
return filter_posts_near(opts[:post_number].to_i) if opts[:post_number].present?
return filter_posts_before(opts[:posts_before].to_i) if opts[:posts_before].present?
return filter_posts_after(opts[:posts_after].to_i) if opts[:posts_after].present?
if opts[:best].present?
return filter_best(opts[:best], opts)
end
return filter_posts_by_ids(opts[:post_ids]) if opts[:post_ids].present?
return filter_best(opts[:best], opts) if opts[:best].present?
filter_posts_paged(opts[:page].to_i)
end
@ -152,36 +146,8 @@ class TopicView
filter_posts_in_range(min, max)
end
# Filter to all posts before a particular post number
def filter_posts_before(post_number)
@initial_load = false
sort_order = sort_order_for_post_number(post_number)
return nil unless sort_order
# Find posts before the `sort_order`
@posts = @filtered_posts.order('sort_order desc').where("sort_order < ?", sort_order)
@index_offset = @posts.count
@index_reverse = true
@posts = @posts.includes(:reply_to_user).includes(:topic).joins(:user).limit(@limit)
end
# Filter to all posts after a particular post number
def filter_posts_after(post_number)
@initial_load = false
sort_order = sort_order_for_post_number(post_number)
return nil unless sort_order
@index_offset = @filtered_posts.where("sort_order <= ?", sort_order).count
@posts = @filtered_posts.order('sort_order').where("sort_order > ?", sort_order)
@posts = @posts.includes(:reply_to_user).includes(:topic).joins(:user).limit(@limit)
end
def filter_best(max, opts={})
@index_offset = 0
if opts[:min_replies] && @topic.posts_count < opts[:min_replies] + 1
@posts = []
return
@ -189,8 +155,10 @@ class TopicView
@posts = @filtered_posts.order('percent_rank asc, sort_order asc')
.where("post_number > 1")
@posts = @posts.includes(:reply_to_user).includes(:topic).joins(:user).limit(max)
min_trust_level = opts[:min_trust_level]
if min_trust_level && min_trust_level > 0
@posts = @posts.where('COALESCE(users.trust_level,0) >= ?', min_trust_level)
@ -233,27 +201,6 @@ class TopicView
@all_post_actions ||= PostAction.counts_for(posts, @user)
end
def voted_in_topic?
return false
# all post_actions is not the way to do this, cut down on the query, roll it up into topic if we need it
@voted_in_topic ||= begin
return false unless all_post_actions.present?
all_post_actions.values.flatten.map {|ac| ac.keys}.flatten.include?(PostActionType.types[:vote])
end
end
def post_action_visibility
@post_action_visibility ||= begin
result = []
PostActionType.types.each do |k, v|
result << v if guardian.can_see_post_actors?(@topic, v)
end
result
end
end
def links
@links ||= TopicLink.topic_summary(guardian, @topic.id)
end
@ -315,6 +262,16 @@ class TopicView
private
def filter_posts_by_ids(post_ids)
# TODO: Sort might be off
@posts = Post.where(id: post_ids)
.includes(:user)
.includes(:reply_to_user)
.order('sort_order')
@posts = @posts.with_deleted if @user.try(:staff?)
@posts
end
def filter_posts_in_range(min, max)
post_count = (filtered_post_ids.length - 1)
@ -324,15 +281,7 @@ class TopicView
min = [[min, max].min, 0].max
@index_offset = min
# TODO: Sort might be off
@posts = Post.where(id: filtered_post_ids[min..max])
.includes(:user)
.includes(:reply_to_user)
.order('sort_order')
@posts = @posts.with_deleted if @user.try(:staff?)
@posts = filter_posts_by_ids(filtered_post_ids[min..max])
@posts
end

View file

@ -35,7 +35,7 @@ describe TopicView do
# should not get the status post
best = TopicView.new(topic.id, nil, best: 99)
best.posts.count.should == 2
best.filtered_posts_count.should == 3
best.filtered_post_ids.size.should == 3
best.current_post_ids.should =~ [p2.id, p3.id]
# should get no results for trust level too low
@ -145,12 +145,6 @@ describe TopicView do
end
end
context '.post_action_visibility' do
it "is allows users to see likes" do
topic_view.post_action_visibility.include?(PostActionType.types[:like]).should be_true
end
end
context '.read?' do
it 'is unread with no logged in user' do
TopicView.new(topic.id).read?(1).should be_false
@ -216,36 +210,6 @@ describe TopicView do
end
end
describe "filter_posts_after" do
it "returns undeleted posts after a post" do
topic_view.filter_posts_after(p1.post_number).map(&:id).should == [p2.id, p3.id, p5.id]
topic_view.should_not be_initial_load
topic_view.index_offset.should == 1
topic_view.index_reverse.should be_false
end
it "clips to the end boundary" do
topic_view.filter_posts_after(p2.post_number).should == [p3, p5]
topic_view.index_offset.should == 2
topic_view.index_reverse.should be_false
end
it "returns nothing after the last post" do
topic_view.filter_posts_after(p5.post_number).should be_blank
end
it "returns nothing after an invalid post number" do
topic_view.filter_posts_after(1000).should be_blank
end
it "returns deleted posts to an admin" do
coding_horror.admin = true
topic_view.filter_posts_after(p1.post_number).should == [p2, p3, p4]
topic_view.index_offset.should == 1
topic_view.index_reverse.should be_false
end
end
describe '#filter_posts_paged' do
before { SiteSetting.stubs(:posts_per_page).returns(1) }
@ -257,37 +221,6 @@ describe TopicView do
end
end
describe "filter_posts_before" do
it "returns undeleted posts before a post" do
topic_view.filter_posts_before(p5.post_number).should == [p3, p2, p1]
topic_view.should_not be_initial_load
topic_view.index_offset.should == 3
topic_view.index_reverse.should be_true
end
it "clips to the beginning boundary" do
topic_view.filter_posts_before(p3.post_number).should == [p2, p1]
topic_view.index_offset.should == 2
topic_view.index_reverse.should be_true
end
it "returns nothing before the first post" do
topic_view.filter_posts_before(p1.post_number).should be_blank
end
it "returns nothing before an invalid post number" do
topic_view.filter_posts_before(-10).should be_blank
topic_view.filter_posts_before(1000).should be_blank
end
it "returns deleted posts to an admin" do
coding_horror.admin = true
topic_view.filter_posts_before(p5.post_number).should == [p4, p3, p2]
topic_view.index_offset.should == 4
topic_view.index_reverse.should be_true
end
end
describe "filter_posts_near" do
def topic_view_near(post)
@ -297,30 +230,22 @@ describe TopicView do
it "snaps to the lower boundary" do
near_view = topic_view_near(p1)
near_view.posts.should == [p1, p2, p3]
near_view.index_offset.should == 0
near_view.index_reverse.should be_false
end
it "snaps to the upper boundary" do
near_view = topic_view_near(p5)
near_view.posts.should == [p2, p3, p5]
near_view.index_offset.should == 1
near_view.index_reverse.should be_false
end
it "returns the posts in the middle" do
near_view = topic_view_near(p2)
near_view.posts.should == [p1, p2, p3]
near_view.index_offset.should == 0
near_view.index_reverse.should be_false
end
it "returns deleted posts to an admin" do
coding_horror.admin = true
near_view = topic_view_near(p3)
near_view.posts.should == [p2, p3, p4]
near_view.index_offset.should == 1
near_view.index_reverse.should be_false
end
context "when 'posts per page' exceeds the number of posts" do
@ -329,8 +254,6 @@ describe TopicView do
it 'returns all the posts' do
near_view = topic_view_near(p5)
near_view.posts.should == [p1, p2, p3, p5]
near_view.index_offset.should == 0
near_view.index_reverse.should be_false
end
end
end

View file

@ -517,17 +517,6 @@ describe TopicsController do
TopicView.any_instance.expects(:filter_posts_near).with(p2.post_number)
xhr :get, :show, topic_id: topic.id, slug: topic.slug, post_number: p2.post_number
end
it 'delegates a posts_after param to TopicView#filter_posts_after' do
TopicView.any_instance.expects(:filter_posts_after).with(p1.post_number)
xhr :get, :show, topic_id: topic.id, slug: topic.slug, posts_after: p1.post_number
end
it 'delegates a posts_before param to TopicView#filter_posts_before' do
TopicView.any_instance.expects(:filter_posts_before).with(p2.post_number)
xhr :get, :show, topic_id: topic.id, slug: topic.slug, posts_before: p2.post_number
end
end
context "when 'login required' site setting has been enabled" do

View file

@ -15,7 +15,9 @@
{ "path": "script" },
{ "path": "cookbooks" },
{ "path": "spec" },
{ "path": "test" }
{ "path": "test",
"folder_exclude_patterns": ["fixtures"]
}
],
"settings":
{

View file

@ -0,0 +1,35 @@
var topic = Discourse.Topic.create({
title: "Qunit Test Topic",
participants: [
{id: 1234,
post_count: 4,
username: "eviltrout"}
]
});
module("Discourse.TopicController", {
setup: function() {
this.topicController = controllerFor('topic', topic);
}
});
test("editingMode", function() {
var topicController = this.topicController;
ok(!topicController.get('editingTopic'), "we are not editing by default");
topicController.set('model.details.can_edit', false);
topicController.editTopic();
ok(!topicController.get('editingTopic'), "calling editTopic doesn't enable editing unless the user can edit");
topicController.set('model.details.can_edit', true);
topicController.editTopic();
ok(topicController.get('editingTopic'), "calling editTopic enables editing if the user can edit");
equal(topicController.get('newTitle'), topic.get('title'));
equal(topicController.get('newCategoryId'), topic.get('category_id'));
topicController.cancelEditingTopic();
ok(!topicController.get('editingTopic'), "cancelling edit mode reverts the property value");
});

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,11 @@
// Test helpers
var resolvingPromise = Ember.Deferred.promise(function (p) {
p.resolve();
})
});
var resolvingPromiseWith = function(result) {
return Ember.Deferred.promise(function (p) { p.resolve(result); });
};
function exists(selector) {
return !!count(selector);
@ -11,22 +15,14 @@ function count(selector) {
return find(selector).length;
}
function objBlank(obj) {
if (obj === undefined) return true;
switch (typeof obj) {
case "string":
return obj.trim().length === 0;
case "object":
return $.isEmptyObject(obj);
}
return false;
}
function present(obj, text) {
equal(objBlank(obj), false, text);
ok(!Ember.isEmpty(obj), text);
}
function blank(obj, text) {
equal(objBlank(obj), true, text);
ok(Ember.isEmpty(obj), text);
}
function containsInstance(collection, klass, text) {
ok(klass.detectInstance(_.first(collection)), text);
}

View file

@ -12,4 +12,10 @@ function integration(name) {
Discourse.ScrollingDOMMethods.unbindOnScroll.restore();
}
});
}
function controllerFor(controller, model) {
var controller = Discourse.__container__.lookup('controller:' + controller);
if (model) { controller.set('model', model ); }
return controller;
}

View file

@ -121,7 +121,11 @@ var jsHintOpts = {
"start",
"_",
"console",
"alert"],
"alert",
"controllerFor",
"containsInstance",
"deepEqual",
"resolvingPromiseWith"],
"node" : false,
"browser" : true,
"boss" : true,

View file

@ -0,0 +1,324 @@
module("Discourse.PostStream");
var buildStream = function(id, stream) {
var topic = Discourse.Topic.create({id: id});
var ps = topic.get('postStream');
if (stream) {
ps.set('stream', stream);
}
return ps;
};
var participant = {username: 'eviltrout'};
test('defaults', function() {
var postStream = buildStream(1234);
blank(postStream.get('posts'), "there are no posts in a stream by default");
ok(!postStream.get('loaded'), "it has never loaded");
present(postStream.get('topic'));
});
test('appending posts', function() {
var postStream = buildStream(4567, [1, 3, 4]);
ok(!postStream.get('hasPosts'), "there are no posts by default");
ok(!postStream.get('firstPostLoaded'), "the first post is not loaded");
ok(!postStream.get('lastPostLoaded'), "the last post is not loaded");
equal(postStream.get('posts.length'), 0, "it has no posts initially");
postStream.appendPost(Discourse.Post.create({id: 2, post_number: 2}));
ok(!postStream.get('firstPostLoaded'), "the first post is still not loaded");
ok(!postStream.get('lastPostLoaded'), "the last post is still not loaded");
equal(postStream.get('posts.length'), 1, "it has one post in the stream");
postStream.appendPost(Discourse.Post.create({id: 4, post_number: 4}));
ok(!postStream.get('firstPostLoaded'), "the first post is still loaded");
ok(postStream.get('lastPostLoaded'), "the last post is now loaded");
equal(postStream.get('posts.length'), 2, "it has two posts in the stream");
postStream.appendPost(Discourse.Post.create({id: 4, post_number: 4}));
equal(postStream.get('posts.length'), 2, "it will not add the same post with id twice");
var stagedPost = Discourse.Post.create({raw: 'incomplete post'});
postStream.appendPost(stagedPost);
equal(postStream.get('posts.length'), 3, "it can handle posts without ids");
postStream.appendPost(stagedPost);
equal(postStream.get('posts.length'), 3, "it won't add the same post without an id twice");
// change the stream
postStream.set('stream', [1, 2, 4]);
ok(!postStream.get('firstPostLoaded'), "the first post no longer loaded since the stream changed.");
ok(postStream.get('lastPostLoaded'), "the last post is still the last post in the new stream");
});
test('updateFromJson', function() {
var postStream = buildStream(1231);
postStream.updateFromJson({
posts: [{id: 1}],
stream: [1],
extra_property: 12
});
equal(postStream.get('posts.length'), 1, 'it loaded the posts');
containsInstance(postStream.get('posts'), Discourse.Post);
equal(postStream.get('extra_property'), 12);
});
test("cancelFilter", function() {
var postStream = buildStream(1235);
this.stub(postStream, "refresh");
postStream.set('bestOf', true);
postStream.cancelFilter();
ok(!postStream.get('bestOf'), "best of is cancelled");
postStream.toggleParticipant(participant);
postStream.cancelFilter();
blank(postStream.get('userFilters'), "cancelling the filters clears the userFilters");
});
test("toggleParticipant", function() {
var postStream = buildStream(1236);
this.stub(postStream, "refresh");
equal(postStream.get('userFilters.length'), 0, "by default no participants are toggled");
postStream.toggleParticipant(participant.username);
ok(postStream.get('userFilters').contains('eviltrout'), 'eviltrout is in the filters');
postStream.toggleParticipant(participant.username);
blank(postStream.get('userFilters'), "toggling the participant again removes them");
});
test("streamFilters", function() {
var postStream = buildStream(1237);
this.stub(postStream, "refresh");
deepEqual(postStream.get('streamFilters'), {}, "there are no postFilters by default");
ok(postStream.get('hasNoFilters'), "there are no filters by default");
blank(postStream.get("filterDesc"), "there is no description of the filter");
postStream.set('bestOf', true);
deepEqual(postStream.get('streamFilters'), {filter: "best_of"}, "postFilters contains the bestOf flag");
ok(!postStream.get('hasNoFilters'), "now there are filters present");
present(postStream.get("filterDesc"), "there is a description of the filter");
postStream.toggleParticipant(participant.username);
deepEqual(postStream.get('streamFilters'), {
filter: "best_of",
username_filters: ['eviltrout']
}, "streamFilters contains the username we filtered");
});
test("loading", function() {
var postStream = buildStream(1234);
ok(!postStream.get('loading'), "we're not loading by default");
postStream.set('loadingAbove', true);
ok(postStream.get('loading'), "we're loading if loading above");
postStream = buildStream(1234);
postStream.set('loadingBelow', true);
ok(postStream.get('loading'), "we're loading if loading below");
postStream = buildStream(1234);
postStream.set('loadingFilter', true);
ok(postStream.get('loading'), "we're loading if loading a filter");
});
test("nextWindow", function() {
Discourse.SiteSettings.posts_per_page = 5;
var postStream = buildStream(1234, [1,2,3,5,8,9,10,11,13,14,15,16]);
blank(postStream.get('nextWindow'), 'With no posts loaded, the window is blank');
postStream.updateFromJson({ posts: [{id: 1}, {id: 2}] });
deepEqual(postStream.get('nextWindow'), [3,5,8,9,10],
"If we've loaded the first 2 posts, the window should be the 5 after that");
postStream.updateFromJson({ posts: [{id: 13}] });
deepEqual(postStream.get('nextWindow'), [14, 15, 16], "Boundary check: stop at the end.");
postStream.updateFromJson({ posts: [{id: 16}] });
blank(postStream.get('nextWindow'), "Once we've seen everything there's nothing to load.");
});
test("previousWindow", function() {
Discourse.SiteSettings.posts_per_page = 5;
var postStream = buildStream(1234, [1,2,3,5,8,9,10,11,13,14,15,16]);
blank(postStream.get('previousWindow'), 'With no posts loaded, the window is blank');
postStream.updateFromJson({ posts: [{id: 11}, {id: 13}] });
deepEqual(postStream.get('previousWindow'), [3, 5, 8, 9, 10],
"If we've loaded in the middle, it's the previous 5 posts");
postStream.updateFromJson({ posts: [{id: 3}] });
deepEqual(postStream.get('previousWindow'), [1, 2], "Boundary check: stop at the beginning.");
postStream.updateFromJson({ posts: [{id: 1}] });
blank(postStream.get('previousWindow'), "Once we've seen everything there's nothing to load.");
});
test("storePost", function() {
var postStream = buildStream(1234);
var post = Discourse.Post.create({id: 1, post_number: 1, raw: 'initial value'});
var stored = postStream.storePost(post);
equal(post, stored, "it returns the post it stored");
equal(post.get('topic'), postStream.get('topic'), "it creates the topic reference properly");
var dupePost = Discourse.Post.create({id: 1, post_number: 1, raw: 'updated value'});
var storedDupe = postStream.storePost(dupePost);
equal(storedDupe, post, "it returns the previously stored post instead to avoid dupes");
equal(storedDupe.get('raw'), 'updated value', 'it updates the previously stored post');
var postWithoutId = Discourse.Post.create({raw: 'hello world'});
stored = postStream.storePost(postWithoutId);
equal(stored, postWithoutId, "it returns the same post back");
equal(postStream.get('postIdentityMap.length'), 1, "it does not add a new entry into the identity map");
});
test("identity map", function() {
var postStream = buildStream(1234);
var p1 = postStream.appendPost(Discourse.Post.create({id: 1, post_number: 1}));
var p3 = postStream.appendPost(Discourse.Post.create({id: 3, post_number: 4}));
equal(postStream.findLoadedPost(1), p1, "it can return cached posts by id");
blank(postStream.findLoadedPost(4), "it can't find uncached posts");
deepEqual(postStream.listUnloadedIds([10, 11, 12]), [10, 11, 12], "it returns a list of all unloaded ids");
blank(postStream.listUnloadedIds([1, 3]), "if we have loaded all posts it's blank");
deepEqual(postStream.listUnloadedIds([1, 2, 3, 4]), [2, 4], "it only returns unloaded posts");
});
asyncTest("loadIntoIdentityMap with no data", function() {
var postStream = buildStream(1234);
expect(1);
this.stub(Discourse, "ajax");
postStream.loadIntoIdentityMap([]).then(function() {
ok(!Discourse.ajax.calledOnce, "an empty array returned a promise yet performed no ajax request");
start();
});
});
asyncTest("loadIntoIdentityMap with post ids", function() {
var postStream = buildStream(1234);
expect(1);
this.stub(Discourse, "ajax").returns(resolvingPromiseWith({
post_stream: {
posts: [{id: 10, post_number: 10}]
}
}));
postStream.loadIntoIdentityMap([10]).then(function() {
present(postStream.findLoadedPost(10), "it adds the returned post to the store");
start();
});
});
test("staging and undoing a new post", function() {
var postStream = buildStream(10101, [1]);
postStream.appendPost(Discourse.Post.create({id: 1, post_number: 1}));
var user = Discourse.User.create({username: 'eviltrout', name: 'eviltrout', id: 321});
var stagedPost = Discourse.Post.create({ raw: 'hello world this is my new post' });
var topic = postStream.get('topic');
topic.setProperties({
posts_count: 1,
highest_post_number: 1
});
// Stage the new post in the stream
postStream.stagePost(stagedPost, user);
equal(topic.get('highest_post_number'), 2, "it updates the highest_post_number");
ok(postStream.get('loading'), "it is loading while the post is being staged");
equal(topic.get('posts_count'), 2, "it increases the post count");
present(topic.get('last_posted_at'), "it updates last_posted_at");
equal(topic.get('details.last_poster'), user, "it changes the last poster");
equal(stagedPost.get('topic'), topic, "it assigns the topic reference");
equal(stagedPost.get('post_number'), 2, "it is assigned the probable post_number");
equal(postStream.get('filteredPostsCount'), 1, "it retains the filteredPostsCount");
present(stagedPost.get('created_at'), "it is assigned a created date");
ok(postStream.get('posts').contains(stagedPost), "the post is added to the stream");
blank(stagedPost.get('id'), "the post has no id yet");
// Undoing a created post (there was an error)
postStream.undoPost(stagedPost);
ok(!postStream.get('loading'), "it is no longer loading");
equal(topic.get('highest_post_number'), 1, "it reverts the highest_post_number");
equal(topic.get('posts_count'), 1, "it reverts the post count");
equal(postStream.get('filteredPostsCount'), 1, "it retains the filteredPostsCount");
ok(!postStream.get('posts').contains(stagedPost), "the post is removed from the stream");
});
test("staging and committing a post", function() {
var postStream = buildStream(10101, [1]);
postStream.appendPost(Discourse.Post.create({id: 1, post_number: 1}));
var user = Discourse.User.create({username: 'eviltrout', name: 'eviltrout', id: 321});
var stagedPost = Discourse.Post.create({ raw: 'hello world this is my new post' });
var topic = postStream.get('topic');
topic.set('posts_count', 1);
// Stage the new post in the stream
postStream.stagePost(stagedPost, user);
ok(postStream.get('loading'), "it is loading while the post is being staged");
stagedPost.setProperties({ id: 1234, raw: "different raw value" });
equal(postStream.get('filteredPostsCount'), 1, "it retains the filteredPostsCount");
postStream.commitPost(stagedPost);
ok(postStream.get('posts').contains(stagedPost), "the post is still in the stream");
ok(!postStream.get('loading'), "it is no longer loading");
equal(postStream.get('filteredPostsCount'), 2, "it increases the filteredPostsCount");
var found = postStream.findLoadedPost(stagedPost.get('id'));
present(found, "the post is in the identity map");
ok(postStream.indexOf(stagedPost) > -1, "the post is in the stream");
equal(found.get('raw'), 'different raw value', 'it also updated the value in the stream');
});
test('triggerNewPostInStream', function() {
var postStream = buildStream(225566);
this.stub(postStream, 'appendMore');
this.stub(postStream, 'refresh');
postStream.triggerNewPostInStream(null);
ok(!postStream.appendMore.calledOnce, "asking for a null id does nothing");
postStream.toggleBestOf();
postStream.triggerNewPostInStream(1);
ok(!postStream.appendMore.calledOnce, "it will not trigger when bestOf is active");
postStream.cancelFilter();
postStream.toggleParticipant('eviltrout');
postStream.triggerNewPostInStream(1);
ok(!postStream.appendMore.calledOnce, "it will not trigger when a participant filter is active");
postStream.cancelFilter();
postStream.triggerNewPostInStream(1);
ok(!postStream.appendMore.calledOnce, "it wont't delegate to appendMore because the last post is not loaded");
postStream.cancelFilter();
postStream.appendPost(Discourse.Post.create({id: 1, post_number: 2}));
postStream.triggerNewPostInStream(2);
ok(postStream.appendMore.calledOnce, "delegates to appendMore because the last post is loaded");
});

View file

@ -0,0 +1,32 @@
module("Discourse.Post");
test('new_user', function() {
var post = Discourse.Post.create({trust_level: 0});
ok(post.get('new_user'), "post is from a new user");
post.set('trust_level', 1);
ok(!post.get('new_user'), "post is no longer from a new user");
});
test('firstPost', function() {
var post = Discourse.Post.create({post_number: 1});
ok(post.get('firstPost'), "it's the first post");
post.set('post_number', 10);
ok(!post.get('firstPost'), "post is no longer the first post");
});
test('updateFromPost', function() {
var post = Discourse.Post.create({
post_number: 1,
raw: 'hello world'
});
post.updateFromPost(Discourse.Post.create({
raw: 'different raw',
wat: function() { return 123; }
}));
equal(post.get('raw'), "different raw", "raw field updated");
});

View file

@ -0,0 +1,32 @@
module("Discourse.TopicDetails");
var buildDetails = function(id) {
var topic = Discourse.Topic.create({id: id});
return topic.get('details');
};
test('defaults', function() {
var details = buildDetails(1234);
present(details, "the details are present by default");
ok(!details.get('loaded'), "details are not loaded by default");
});
test('updateFromJson', function() {
var details = buildDetails(1234);
details.updateFromJson({
suggested_topics: [{id: 1}, {id: 3}],
allowed_users: [{username: 'eviltrout'}]
});
equal(details.get('suggested_topics.length'), 2, 'it loaded the suggested_topics');
containsInstance(details.get('suggested_topics'), Discourse.Topic);
equal(details.get('allowed_users.length'), 1, 'it loaded the allowed users');
containsInstance(details.get('allowed_users'), Discourse.User);
});

View file

@ -0,0 +1,39 @@
module("Discourse.Topic");
test('has details', function() {
var topic = Discourse.Topic.create({id: 1234});
var topicDetails = topic.get('details');
present(topicDetails, "a topic has topicDetails after we create it");
equal(topicDetails.get('topic'), topic, "the topicDetails has a reference back to the topic");
});
test('has a postStream', function() {
var topic = Discourse.Topic.create({id: 1234});
var postStream = topic.get('postStream');
present(postStream, "a topic has a postStream after we create it");
equal(postStream.get('topic'), topic, "the postStream has a reference back to the topic");
});
var category = _.first(Discourse.Category.list());
test('category relationship', function() {
// It finds the category by id
var topic = Discourse.Topic.create({id: 1111, category_id: category.get('id') });
equal(topic.get('category'), category);
});
test("updateFromJson", function() {
var topic = Discourse.Topic.create({id: 1234});
topic.updateFromJson({
post_stream: [1,2,3],
details: {hello: 'world'},
cool: 'property',
category_id: category.get('id')
});
blank(topic.get('post_stream'), "it does not update post_stream");
equal(topic.get('details.hello'), 'world', 'it updates the details');
equal(topic.get('cool'), "property", "it updates other properties");
equal(topic.get('category'), category);
});