Big commit:

- Support for a popup that shows similar topics
- Cleaned up a lot of Javascript
- Cleaned up use of Promises
This commit is contained in:
Robin Ward 2013-03-14 14:45:29 -04:00
parent 7714e2050e
commit ad082cea70
39 changed files with 584 additions and 560 deletions

View file

@ -456,7 +456,7 @@ GEM
turbo-sprockets-rails3 (0.3.6) turbo-sprockets-rails3 (0.3.6)
railties (> 3.2.8, < 4.0.0) railties (> 3.2.8, < 4.0.0)
sprockets (>= 2.0.0) sprockets (>= 2.0.0)
tzinfo (0.3.35) tzinfo (0.3.37)
uglifier (1.3.0) uglifier (1.3.0)
execjs (>= 0.3.0) execjs (>= 0.3.0)
multi_json (~> 1.0, >= 1.0.2) multi_json (~> 1.0, >= 1.0.2)

View file

@ -156,15 +156,9 @@ Discourse.AdminUser.reopenClass({
}, },
find: function(username) { find: function(username) {
var promise; return $.ajax({url: "/admin/users/" + username}).then(function (result) {
promise = new RSVP.Promise(); return Discourse.AdminUser.create(result);
$.ajax({ })
url: "/admin/users/" + username,
success: function(result) {
return promise.resolve(Discourse.AdminUser.create(result));
}
});
return promise;
}, },
findAll: function(query, filter) { findAll: function(query, filter) {

View file

@ -46,47 +46,15 @@ Discourse.FlaggedPost = Discourse.Post.extend({
}).property('topic_hidden'), }).property('topic_hidden'),
deletePost: function() { deletePost: function() {
var promise;
promise = new RSVP.Promise();
if (this.get('post_number') === "1") { if (this.get('post_number') === "1") {
return $.ajax("/t/" + this.topic_id, { return $.ajax("/t/" + this.topic_id, { type: 'DELETE', cache: false });
type: 'DELETE',
cache: false,
success: function() {
promise.resolve();
},
error: function(e) {
promise.reject();
}
});
} else { } else {
return $.ajax("/posts/" + this.id, { return $.ajax("/posts/" + this.id, { type: 'DELETE', cache: false });
type: 'DELETE',
cache: false,
success: function() {
promise.resolve();
},
error: function(e) {
promise.reject();
}
});
} }
}, },
clearFlags: function() { clearFlags: function() {
var promise; return $.ajax("/admin/flags/clear/" + this.id, { type: 'POST', cache: false });
promise = new RSVP.Promise();
$.ajax("/admin/flags/clear/" + this.id, {
type: 'POST',
cache: false,
success: function() {
promise.resolve();
},
error: function(e) {
promise.reject();
}
});
return promise;
}, },
hiddenClass: (function() { hiddenClass: (function() {

View file

@ -26,14 +26,8 @@ Discourse.VersionCheck = Discourse.Model.extend({
Discourse.VersionCheck.reopenClass({ Discourse.VersionCheck.reopenClass({
find: function() { find: function() {
var promise = new RSVP.Promise(); return $.ajax({ url: '/admin/version_check', dataType: 'json' }).then(function(json) {
$.ajax({ return Discourse.VersionCheck.create(json);
url: '/admin/version_check',
dataType: 'json',
success: function(json) {
promise.resolve(Discourse.VersionCheck.create(json));
}
}); });
return promise;
} }
}); });

View file

@ -8,22 +8,20 @@
**/ **/
Discourse.ComposerController = Discourse.Controller.extend({ Discourse.ComposerController = Discourse.Controller.extend({
needs: ['modal', 'topic'], needs: ['modal', 'topic'],
hasReply: false,
togglePreview: function() { togglePreview: function() {
return this.get('content').togglePreview(); this.get('content').togglePreview();
}, },
// Import a quote from the post // Import a quote from the post
importQuote: function() { importQuote: function() {
return this.get('content').importQuote(); this.get('content').importQuote();
}, },
appendText: function(text) { appendText: function(text) {
var c; var c = this.get('content');
c = this.get('content');
if (c) { if (c) {
return c.appendText(text); c.appendText(text);
} }
}, },
@ -72,7 +70,6 @@ Discourse.ComposerController = Discourse.Controller.extend({
} }
bootbox.dialog(message, buttons); bootbox.dialog(message, buttons);
return; return;
} }
} }
@ -94,17 +91,80 @@ Discourse.ComposerController = Discourse.Controller.extend({
}); });
}, },
checkReplyLength: function() { closeEducation: function() {
if (this.present('content.reply')) { this.set('educationClosed', true);
this.set('hasReply', true); },
} else {
this.set('hasReply', false); closeSimilar: function() {
this.set('similarClosed', true);
},
similarVisible: function() {
if (this.get('similarClosed')) return false;
if (this.get('content.composeState') !== Discourse.Composer.OPEN) return false;
return (this.get('similarTopics.length') || 0) > 0;
}.property('similarTopics.length', 'similarClosed', 'content.composeState'),
newUserEducationVisible: function() {
if (!this.get('educationContents')) return false;
if (this.get('content.composeState') !== Discourse.Composer.OPEN) return false;
if (!this.present('content.reply')) return false;
if (this.get('educationClosed')) return false;
return true;
}.property('content.composeState', 'content.reply', 'educationClosed', 'educationContents'),
fetchNewUserEducation: function() {
// If creating a topic, use topic_count, otherwise post_count
var count = this.get('content.creatingTopic') ? Discourse.get('currentUser.topic_count') : Discourse.get('currentUser.reply_count');
if (count >= Discourse.SiteSettings.educate_until_posts) {
this.set('educationClosed', true);
this.set('educationContents', '');
return;
} }
// The user must have typed a reply
if (!this.get('typedReply')) return;
this.set('educationClosed', false);
// If visible update the text
var educationKey = this.get('content.creatingTopic') ? 'new-topic' : 'new-reply';
var composerController = this;
$.get("/education/" + educationKey).then(function(result) {
composerController.set('educationContents', result);
});
}.observes('typedReply', 'content.creatingTopic', 'Discourse.currentUser.reply_count'),
checkReplyLength: function() {
this.set('typedReply', this.present('content.reply'));
},
/**
Fired after a user stops typing. Considers whether to check for similar
topics based on the current composer state.
@method findSimilarTopics
**/
findSimilarTopics: function() {
// We don't care about similar topics unless creating a topic
if (!this.get('content.creatingTopic')) return;
var body = this.get('content.reply');
var title = this.get('content.title');
// Ensure the fields are of the minimum length
if (body.length < Discourse.SiteSettings.min_body_similar_length) return;
if (title.length < Discourse.SiteSettings.min_title_similar_length) return;
var composerController = this;
Discourse.Topic.findSimilarTo(title, body).then(function (topics) {
composerController.set('similarTopics', topics);
});
}, },
saveDraft: function() { saveDraft: function() {
var model; var model = this.get('content');
model = this.get('content');
if (model) model.saveDraft(); if (model) model.saveDraft();
}, },
@ -123,8 +183,11 @@ Discourse.ComposerController = Discourse.Controller.extend({
_this = this; _this = this;
if (!opts) opts = {}; if (!opts) opts = {};
opts.promise = promise = opts.promise || new RSVP.Promise(); opts.promise = promise = opts.promise || Ember.Deferred.create();
this.set('hasReply', false); this.set('typedReply', false);
this.set('similarTopics', null);
this.set('similarClosed', false);
if (!opts.draftKey) { if (!opts.draftKey) {
alert("composer was opened without a draft key"); alert("composer was opened without a draft key");
throw "composer opened without a proper draft key"; throw "composer opened without a proper draft key";
@ -133,9 +196,7 @@ Discourse.ComposerController = Discourse.Controller.extend({
// ensure we have a view now, without it transitions are going to be messed // ensure we have a view now, without it transitions are going to be messed
view = this.get('view'); view = this.get('view');
if (!view) { if (!view) {
view = Discourse.ComposerView.create({ view = Discourse.ComposerView.create({ controller: this });
controller: this
});
view.appendTo($('#main')); view.appendTo($('#main'));
this.set('view', view); this.set('view', view);
// the next runloop is too soon, need to get the control rendered and then // the next runloop is too soon, need to get the control rendered and then
@ -197,8 +258,7 @@ Discourse.ComposerController = Discourse.Controller.extend({
}, },
wouldLoseChanges: function() { wouldLoseChanges: function() {
var composer; var composer = this.get('content');
composer = this.get('content');
return composer && composer.wouldLoseChanges(); return composer && composer.wouldLoseChanges();
}, },
@ -210,10 +270,9 @@ Discourse.ComposerController = Discourse.Controller.extend({
}, },
destroyDraft: function() { destroyDraft: function() {
var key; var key = this.get('content.draftKey');
key = this.get('content.draftKey');
if (key) { if (key) {
return Discourse.Draft.clear(key, this.get('content.draftSequence')); Discourse.Draft.clear(key, this.get('content.draftSequence'));
} }
}, },
@ -243,17 +302,17 @@ Discourse.ComposerController = Discourse.Controller.extend({
} }
}, },
click: function() { openIfDraft: function() {
if (this.get('content.composeState') === Discourse.Composer.DRAFT) { if (this.get('content.composeState') === Discourse.Composer.DRAFT) {
return this.set('content.composeState', Discourse.Composer.OPEN); this.set('content.composeState', Discourse.Composer.OPEN);
} }
}, },
shrink: function() { shrink: function() {
if (this.get('content.reply') === this.get('content.originalText')) { if (this.get('content.reply') === this.get('content.originalText')) {
return this.close(); this.close();
} else { } else {
return this.collapse(); this.collapse();
} }
}, },

View file

@ -42,13 +42,11 @@ Discourse.ListController = Discourse.Controller.extend({
this.set('loading', true); this.set('loading', true);
if (filterMode === 'categories') { if (filterMode === 'categories') {
return Ember.Deferred.promise(function(deferred) { return Discourse.CategoryList.list(filterMode).then(function(items) {
Discourse.CategoryList.list(filterMode).then(function(items) { listController.set('loading', false);
listController.set('loading', false); listController.set('filterMode', filterMode);
listController.set('filterMode', filterMode); listController.set('categoryMode', true);
listController.set('categoryMode', true); return items;
return deferred.resolve(items);
});
}); });
} }
@ -56,13 +54,11 @@ Discourse.ListController = Discourse.Controller.extend({
if (!current) { if (!current) {
current = Discourse.NavItem.create({ name: filterMode }); current = Discourse.NavItem.create({ name: filterMode });
} }
return Ember.Deferred.promise(function(deferred) { return Discourse.TopicList.list(current).then(function(items) {
Discourse.TopicList.list(current).then(function(items) { listController.set('filterSummary', items.filter_summary);
listController.set('filterSummary', items.filter_summary); listController.set('filterMode', filterMode);
listController.set('filterMode', filterMode); listController.set('loading', false);
listController.set('loading', false); return items;
return deferred.resolve(items);
});
}); });
}, },

View file

@ -117,19 +117,18 @@ Discourse.TopicController = Discourse.ObjectController.extend({
}, },
replyAsNewTopic: function(post) { replyAsNewTopic: function(post) {
var composerController, postLink, postUrl, promise;
composerController = this.get('controllers.composer');
// TODO shut down topic draft cleanly if it exists ... // TODO shut down topic draft cleanly if it exists ...
promise = composerController.open({ var composerController = this.get('controllers.composer');
var promise = composerController.open({
action: Discourse.Composer.CREATE_TOPIC, action: Discourse.Composer.CREATE_TOPIC,
draftKey: Discourse.Composer.REPLY_AS_NEW_TOPIC_KEY draftKey: Discourse.Composer.REPLY_AS_NEW_TOPIC_KEY
}); });
postUrl = "" + location.protocol + "//" + location.host + (post.get('url')); var postUrl = "" + location.protocol + "//" + location.host + (post.get('url'));
postLink = "[" + (this.get('title')) + "](" + postUrl + ")"; var postLink = "[" + (this.get('title')) + "](" + postUrl + ")";
return promise.then(function() {
return Discourse.Post.loadQuote(post.get('id')).then(function(q) { promise.then(function() {
return composerController.appendText("" + (Em.String.i18n("post.continue_discussion", { Discourse.Post.loadQuote(post.get('id')).then(function(q) {
composerController.appendText("" + (Em.String.i18n("post.continue_discussion", {
postLink: postLink postLink: postLink
})) + "\n\n" + q); })) + "\n\n" + q);
}); });

View file

@ -40,8 +40,6 @@ Discourse.ActionSummary = Discourse.Model.extend({
act: function(opts) { act: function(opts) {
// Mark it as acted // Mark it as acted
var promise,
_this = this;
this.set('acted', true); this.set('acted', true);
this.set('count', this.get('count') + 1); this.set('count', this.get('count') + 1);
this.set('can_act', false); this.set('can_act', false);
@ -53,26 +51,19 @@ Discourse.ActionSummary = Discourse.Model.extend({
} }
// Create our post action // Create our post action
promise = new RSVP.Promise(); var actionSummary = this;
$.ajax({ return $.ajax({
url: "/post_actions", url: "/post_actions",
type: 'POST', type: 'POST',
data: { data: {
id: this.get('post.id'), id: this.get('post.id'),
post_action_type_id: this.get('id'), post_action_type_id: this.get('id'),
message: (opts ? opts.message : void 0) || "" message: (opts ? opts.message : void 0) || ""
},
error: function(error) {
var errors;
_this.removeAction();
errors = $.parseJSON(error.responseText).errors;
return promise.reject(errors);
},
success: function() {
return promise.resolve();
} }
}).then(null, function (error) {
actionSummary.removeAction();
return $.parseJSON(error.responseText).errors;
}); });
return promise;
}, },
// Undo this action // Undo this action

View file

@ -31,18 +31,14 @@ Discourse.CategoryList.reopenClass({
}, },
list: function(filter) { list: function(filter) {
var promise, var route = this;
_this = this; return $.getJSON("/" + filter + ".json").then(function(result) {
promise = new RSVP.Promise(); var categoryList = Discourse.TopicList.create();
$.getJSON("/" + filter + ".json").then(function(result) {
var categoryList;
categoryList = Discourse.TopicList.create();
categoryList.set('can_create_category', result.category_list.can_create_category); categoryList.set('can_create_category', result.category_list.can_create_category);
categoryList.set('categories', _this.categoriesFrom(result)); categoryList.set('categories', route.categoriesFrom(result));
categoryList.set('loaded', true); categoryList.set('loaded', true);
return promise.resolve(categoryList); return categoryList;
}); });
return promise;
} }
}); });

View file

@ -85,9 +85,9 @@ Discourse.Composer = Discourse.Model.extend({
if (post) { if (post) {
this.set('loading', true); this.set('loading', true);
var composer = this; var composer = this;
return Discourse.Post.load(post.get('id'), function(result) { Discourse.Post.load(post.get('id')).then(function(result) {
composer.appendText(Discourse.BBCode.buildQuoteBBCode(post, result.get('raw'))); composer.appendText(Discourse.BBCode.buildQuoteBBCode(post, result.get('raw')));
return composer.set('loading', false); composer.set('loading', false);
}); });
} }
}, },
@ -249,9 +249,10 @@ Discourse.Composer = Discourse.Model.extend({
this.set('reply', opts.reply || this.get("reply") || ""); this.set('reply', opts.reply || this.get("reply") || "");
if (opts.postId) { if (opts.postId) {
this.set('loading', true); this.set('loading', true);
Discourse.Post.load(opts.postId, function(result) { Discourse.Post.load(opts.postId).then(function(result) {
console.log(result);
composer.set('post', result); composer.set('post', result);
return composer.set('loading', false); composer.set('loading', false);
}); });
} }
@ -259,7 +260,7 @@ Discourse.Composer = Discourse.Model.extend({
if (opts.action === EDIT && opts.post) { if (opts.action === EDIT && opts.post) {
this.set('title', this.get('topic.title')); this.set('title', this.get('topic.title'));
this.set('loading', true); this.set('loading', true);
Discourse.Post.load(opts.post.get('id'), function(result) { Discourse.Post.load(opts.post.get('id')).then(function(result) {
composer.set('reply', result.get('raw')); composer.set('reply', result.get('raw'));
composer.set('originalText', composer.get('reply')); composer.set('originalText', composer.get('reply'));
composer.set('loading', false); composer.set('loading', false);
@ -285,7 +286,6 @@ Discourse.Composer = Discourse.Model.extend({
// When you edit a post // When you edit a post
editPost: function(opts) { editPost: function(opts) {
var promise = new RSVP.Promise();
var post = this.get('post'); var post = this.get('post');
var oldCooked = post.get('cooked'); var oldCooked = post.get('cooked');
var composer = this; var composer = this;
@ -304,40 +304,37 @@ Discourse.Composer = Discourse.Model.extend({
post.set('cooked', $('#wmd-preview').html()); post.set('cooked', $('#wmd-preview').html());
this.set('composeState', CLOSED); this.set('composeState', CLOSED);
return Ember.Deferred.promise(function(promise) {
post.save(function(savedPost) {
var posts = composer.get('topic.posts');
post.save(function(savedPost) { // perhaps our post came from elsewhere eg. draft
var idx = -1;
var idx, postNumber; var postNumber = post.get('post_number');
var posts = composer.get('topic.posts'); posts.each(function(p, i) {
if (p.get('post_number') === postNumber) {
// perhaps our post came from elsewhere eg. draft idx = i;
idx = -1; }
postNumber = post.get('post_number'); });
posts.each(function(p, i) { if (idx > -1) {
if (p.get('post_number') === postNumber) { savedPost.set('topic', composer.get('topic'));
idx = i; posts.replace(idx, 1, [savedPost]);
promise.resolve({ post: post });
composer.set('topic.draft_sequence', savedPost.draft_sequence);
} }
}, function(error) {
var errors;
errors = $.parseJSON(error.responseText).errors;
promise.reject(errors[0]);
post.set('cooked', oldCooked);
return composer.set('composeState', OPEN);
}); });
if (idx > -1) {
savedPost.set('topic', composer.get('topic'));
posts.replace(idx, 1, [savedPost]);
promise.resolve({ post: post });
composer.set('topic.draft_sequence', savedPost.draft_sequence);
}
}, function(error) {
var errors;
errors = $.parseJSON(error.responseText).errors;
promise.reject(errors[0]);
post.set('cooked', oldCooked);
return composer.set('composeState', OPEN);
}); });
return promise;
}, },
// Create a new Post // Create a new Post
createPost: function(opts) { createPost: function(opts) {
var promise = new RSVP.Promise(), var post = this.get('post'),
post = this.get('post'),
topic = this.get('topic'), topic = this.get('topic'),
currentUser = Discourse.get('currentUser'), currentUser = Discourse.get('currentUser'),
addedToStream = false; addedToStream = false;
@ -401,38 +398,37 @@ Discourse.Composer = Discourse.Model.extend({
// Save callback // Save callback
var composer = this; var composer = this;
createdPost.save(function(result) { return Ember.Deferred.promise(function(promise) {
var addedPost = false, createdPost.save(function(result) {
saving = true; var addedPost = false,
createdPost.updateFromSave(result); saving = true;
if (topic) { createdPost.updateFromSave(result);
// It's no longer a new post if (topic) {
createdPost.set('newPost', false); // It's no longer a new post
topic.set('draft_sequence', result.draft_sequence); createdPost.set('newPost', false);
} else { topic.set('draft_sequence', result.draft_sequence);
// We created a new topic, let's show it. } else {
composer.set('composeState', CLOSED); // We created a new topic, let's show it.
saving = false; composer.set('composeState', CLOSED);
} saving = false;
composer.set('reply', ''); }
composer.set('createdPost', createdPost); composer.set('reply', '');
if (addedToStream) { composer.set('createdPost', createdPost);
composer.set('composeState', CLOSED); if (addedToStream) {
} else if (saving) { composer.set('composeState', CLOSED);
composer.set('composeState', SAVING); } else if (saving) {
} composer.set('composeState', SAVING);
return promise.resolve({ post: result }); }
}, function(error) { return promise.resolve({ post: result });
// If an error occurs }, function(error) {
var errors; // If an error occurs
if (topic) { if (topic) {
topic.posts.removeObject(createdPost); topic.posts.removeObject(createdPost);
} }
errors = $.parseJSON(error.responseText).errors; promise.reject($.parseJSON(error.responseText).errors[0]);
promise.reject(errors[0]); composer.set('composeState', OPEN);
composer.set('composeState', OPEN); });
}); });
return promise;
}, },
saveDraft: function() { saveDraft: function() {

View file

@ -22,20 +22,11 @@ Discourse.Draft.reopenClass({
}, },
get: function(key) { get: function(key) {
var promise, return $.ajax({
_this = this;
promise = new RSVP.Promise();
$.ajax({
url: '/draft', url: '/draft',
data: { data: { draft_key: key },
draft_key: key dataType: 'json'
},
dataType: 'json',
success: function(data) {
return promise.resolve(data);
}
}); });
return promise;
}, },
getLocal: function(key, current) { getLocal: function(key, current) {
@ -44,35 +35,16 @@ Discourse.Draft.reopenClass({
}, },
save: function(key, sequence, data) { save: function(key, sequence, data) {
var promise;
promise = new RSVP.Promise();
data = typeof data === "string" ? data : JSON.stringify(data); data = typeof data === "string" ? data : JSON.stringify(data);
$.ajax({ return $.ajax({
type: 'POST', type: 'POST',
url: "/draft", url: "/draft",
data: { data: {
draft_key: key, draft_key: key,
data: data, data: data,
sequence: sequence sequence: sequence
},
success: function() {
/* don't keep local
*/
/* Discourse.KeyValueStore.remove("draft_#{key}")
*/
return promise.resolve();
},
error: function() {
/* save local
*/
/* Discourse.KeyValueStore.set(key: "draft_#{key}", value: data)
*/
return promise.reject();
} }
}); });
return promise;
} }
}); });

View file

@ -15,28 +15,21 @@ Discourse.InviteList = Discourse.Model.extend({
Discourse.InviteList.reopenClass({ Discourse.InviteList.reopenClass({
findInvitedBy: function(user) { findInvitedBy: function(user) {
var promise; return $.ajax({ url: "/users/" + (user.get('username_lower')) + "/invited.json" }).then(function (result) {
promise = new RSVP.Promise(); var invitedList = result.invited_list;
$.ajax({ if (invitedList.pending) {
url: "/users/" + (user.get('username_lower')) + "/invited.json", invitedList.pending = invitedList.pending.map(function(i) {
success: function(result) { return Discourse.Invite.create(i);
var invitedList; });
invitedList = result.invited_list;
if (invitedList.pending) {
invitedList.pending = invitedList.pending.map(function(i) {
return Discourse.Invite.create(i);
});
}
if (invitedList.redeemed) {
invitedList.redeemed = invitedList.redeemed.map(function(i) {
return Discourse.Invite.create(i);
});
}
invitedList.user = user;
return promise.resolve(Discourse.InviteList.create(invitedList));
} }
if (invitedList.redeemed) {
invitedList.redeemed = invitedList.redeemed.map(function(i) {
return Discourse.Invite.create(i);
});
}
invitedList.user = user;
return Discourse.InviteList.create(invitedList);
}); });
return promise;
} }
}); });

View file

@ -215,22 +215,19 @@ Discourse.Post = Discourse.Model.extend({
// Load replies to this post // Load replies to this post
loadReplies: function() { loadReplies: function() {
var promise,
_this = this;
promise = new RSVP.Promise();
this.set('loadingReplies', true); this.set('loadingReplies', true);
this.set('replies', []); this.set('replies', []);
$.getJSON("/posts/" + (this.get('id')) + "/replies", function(loaded) {
var parent = this;
return $.ajax({url: "/posts/" + (this.get('id')) + "/replies"}).then(function(loaded) {
var replies = parent.get('replies');
loaded.each(function(reply) { loaded.each(function(reply) {
var post; var post = Discourse.Post.create(reply);
post = Discourse.Post.create(reply); post.set('topic', parent.get('topic'));
post.set('topic', _this.get('topic')); replies.pushObject(post);
return _this.get('replies').pushObject(post);
}); });
_this.set('loadingReplies', false); parent.set('loadingReplies', false);
return promise.resolve();
}); });
return promise;
}, },
loadVersions: function(callback) { loadVersions: function(callback) {
@ -293,43 +290,33 @@ Discourse.Post.reopenClass({
return $.ajax("/posts/destroy_many", { return $.ajax("/posts/destroy_many", {
type: 'DELETE', type: 'DELETE',
data: { data: {
post_ids: posts.map(function(p) { post_ids: posts.map(function(p) { return p.get('id'); })
return p.get('id');
})
} }
}); });
}, },
loadVersion: function(postId, version, callback) { loadVersion: function(postId, version, callback) {
var _this = this; return $.ajax({url: "/posts/" + postId + ".json?version=" + version}).then(function(result) {
return $.getJSON("/posts/" + postId + ".json?version=" + version, function(result) { return Discourse.Post.create(result);
return callback(Discourse.Post.create(result));
}); });
}, },
loadByPostNumber: function(topicId, postId, callback) { loadByPostNumber: function(topicId, postId) {
var _this = this; return $.ajax({url: "/posts/by_number/" + topicId + "/" + postId + ".json"}).then(function (result) {
return $.getJSON("/posts/by_number/" + topicId + "/" + postId + ".json", function(result) { return Discourse.Post.create(result);
return callback(Discourse.Post.create(result));
}); });
}, },
loadQuote: function(postId) { loadQuote: function(postId) {
var promise, return $.ajax({url: "/posts/" + postId + ".json"}).then(function(result) {
_this = this; var post = Discourse.Post.create(result);
promise = new RSVP.Promise(); return Discourse.BBCode.buildQuoteBBCode(post, post.get('raw'));
$.getJSON("/posts/" + postId + ".json", function(result) {
var post;
post = Discourse.Post.create(result);
return promise.resolve(Discourse.BBCode.buildQuoteBBCode(post, post.get('raw')));
}); });
return promise;
}, },
load: function(postId, callback) { load: function(postId) {
var _this = this; return $.ajax({url: "/posts/" + postId + ".json"}).then(function (result) {
return $.getJSON("/posts/" + postId + ".json", function(result) { return Discourse.Post.create(result);
return callback(Discourse.Post.create(result));
}); });
} }

View file

@ -372,6 +372,20 @@ Discourse.Topic.reopenClass({
MUTE: 0 MUTE: 0
}, },
/**
Find similar topics to a given title and body
@method findSimilar
@param {String} title The current title
@param {String} body The current body
@returns A promise that will resolve to the topics
**/
findSimilarTo: function(title, body) {
return $.ajax({url: "/topics/similar_to", data: {title: title, raw: body} }).then(function (results) {
return results.map(function(topic) { return Discourse.Topic.create(topic) });
});
},
// Load a topic, but accepts a set of filters // Load a topic, but accepts a set of filters
// options: // options:
// onLoad - the callback after the topic is loaded // onLoad - the callback after the topic is loaded
@ -408,20 +422,15 @@ Discourse.Topic.reopenClass({
} }
// Check the preload store. If not, load it via JSON // Check the preload store. If not, load it via JSON
promise = new RSVP.Promise(); return PreloadStore.get("topic_" + topicId, function() {
PreloadStore.get("topic_" + topicId, function() {
return $.getJSON(url + ".json", data); return $.getJSON(url + ".json", data);
}).then(function(result) { }).then(function(result) {
var first; var first = result.posts.first();
first = result.posts.first();
if (first && opts && opts.bestOf) { if (first && opts && opts.bestOf) {
first.bestOfFirst = true; first.bestOfFirst = true;
} }
return promise.resolve(result); return result;
}, function(result) {
return promise.reject(result);
}); });
return promise;
}, },
// Create a topic from posts // Create a topic from posts

View file

@ -10,42 +10,38 @@
Discourse.TopicList = Discourse.Model.extend({ Discourse.TopicList = Discourse.Model.extend({
loadMoreTopics: function() { loadMoreTopics: function() {
var moreUrl, promise, var moreUrl, _this = this;
_this = this;
promise = new RSVP.Promise();
if (moreUrl = this.get('more_topics_url')) { if (moreUrl = this.get('more_topics_url')) {
Discourse.URL.replaceState("/" + (this.get('filter')) + "/more"); Discourse.URL.replaceState("/" + (this.get('filter')) + "/more");
$.ajax(moreUrl, { return $.ajax({url: moreUrl}).then(function (result) {
success: function(result) { var newTopics, topicIds, topics, topicsAdded = 0;
var newTopics, topicIds, topics, topicsAdded = 0; if (result) {
if (result) { // the new topics loaded from the server
// the new topics loaded from the server newTopics = Discourse.TopicList.topicsFrom(result);
newTopics = Discourse.TopicList.topicsFrom(result); // the current topics
// the current topics topics = _this.get('topics');
topics = _this.get('topics'); // keeps track of the ids of the current topics
// keeps track of the ids of the current topics topicIds = [];
topicIds = []; topics.each(function(t) {
topics.each(function(t) { topicIds[t.get('id')] = true;
topicIds[t.get('id')] = true; });
}); // add new topics to the list of current topics if not already present
// add new topics to the list of current topics if not already present newTopics.each(function(t) {
newTopics.each(function(t) { if (!topicIds[t.get('id')]) {
if (!topicIds[t.get('id')]) { // highlight the first of the new topics so we can get a visual feedback
// highlight the first of the new topics so we can get a visual feedback t.set('highlight', topicsAdded++ === 0);
t.set('highlight', topicsAdded++ === 0); return topics.pushObject(t);
return topics.pushObject(t); }
} });
}); _this.set('more_topics_url', result.topic_list.more_topics_url);
_this.set('more_topics_url', result.topic_list.more_topics_url); Discourse.set('transient.topicsList', _this);
Discourse.set('transient.topicsList', _this);
}
return promise.resolve(result.topic_list.more_topics_url ? true : false);
} }
return result.topic_list.more_topics_url;
}); });
} else { } else {
promise.resolve(false); return null;
} }
return promise;
}, },
insert: function(json) { insert: function(json) {
@ -90,7 +86,7 @@ Discourse.TopicList.reopenClass({
}, },
list: function(menuItem) { list: function(menuItem) {
var filter, found, list, promise, topic_list, url; var filter, list, promise, topic_list, url;
filter = menuItem.name; filter = menuItem.name;
topic_list = Discourse.TopicList.create(); topic_list = Discourse.TopicList.create();
topic_list.set('inserted', Em.A()); topic_list.set('inserted', Em.A());
@ -101,19 +97,16 @@ Discourse.TopicList.reopenClass({
} }
if (list = Discourse.get('transient.topicsList')) { if (list = Discourse.get('transient.topicsList')) {
if ((list.get('filter') === filter) && window.location.pathname.indexOf('more') > 0) { if ((list.get('filter') === filter) && window.location.pathname.indexOf('more') > 0) {
promise = new RSVP.Promise();
list.set('loaded', true); list.set('loaded', true);
promise.resolve(list); return Ember.Deferred.promise(function(promise) {
return promise; promise.resolve(list);
});
} }
} }
Discourse.set('transient.topicsList', null); Discourse.set('transient.topicsList', null);
Discourse.set('transient.topicListScrollPos', null); Discourse.set('transient.topicListScrollPos', null);
promise = new RSVP.Promise();
found = PreloadStore.contains('topic_list'); return PreloadStore.get("topic_list", function() { return $.getJSON(url) }).then(function(result) {
PreloadStore.get("topic_list", function() {
return $.getJSON(url);
}).then(function(result) {
topic_list.set('topics', Discourse.TopicList.topicsFrom(result)); topic_list.set('topics', Discourse.TopicList.topicsFrom(result));
topic_list.set('can_create_topic', result.topic_list.can_create_topic); topic_list.set('can_create_topic', result.topic_list.can_create_topic);
topic_list.set('more_topics_url', result.topic_list.more_topics_url); topic_list.set('more_topics_url', result.topic_list.more_topics_url);
@ -125,9 +118,8 @@ Discourse.TopicList.reopenClass({
topic_list.set('category', Discourse.Category.create(result.topic_list.filtered_category)); topic_list.set('category', Discourse.Category.create(result.topic_list.filtered_category));
} }
topic_list.set('loaded', true); topic_list.set('loaded', true);
return promise.resolve(topic_list); return topic_list;
}); });
return promise;
} }
}); });

View file

@ -18,7 +18,7 @@ Discourse.User = Discourse.Model.extend({
return Discourse.Utilities.avatarUrl(this.get('username'), 'large', this.get('avatar_template')); return Discourse.Utilities.avatarUrl(this.get('username'), 'large', this.get('avatar_template'));
}).property('username'), }).property('username'),
/** /**
Small version of this user's avatar. Small version of this user's avatar.
@property avatarSmall @property avatarSmall
@ -68,7 +68,7 @@ Discourse.User = Discourse.Model.extend({
return Discourse.get('site.trust_levels').findProperty('id', this.get('trust_level')); return Discourse.get('site.trust_levels').findProperty('id', this.get('trust_level'));
}).property('trust_level'), }).property('trust_level'),
/** /**
Changes this user's username. Changes this user's username.
@method changeUsername @method changeUsername
@ -76,7 +76,7 @@ Discourse.User = Discourse.Model.extend({
@returns Result of ajax call @returns Result of ajax call
**/ **/
changeUsername: function(newUsername) { changeUsername: function(newUsername) {
return jQuery.ajax({ return $.ajax({
url: "/users/" + (this.get('username_lower')) + "/preferences/username", url: "/users/" + (this.get('username_lower')) + "/preferences/username",
type: 'PUT', type: 'PUT',
data: { data: {
@ -93,7 +93,7 @@ Discourse.User = Discourse.Model.extend({
@returns Result of ajax call @returns Result of ajax call
**/ **/
changeEmail: function(email) { changeEmail: function(email) {
return jQuery.ajax({ return $.ajax({
url: "/users/" + (this.get('username_lower')) + "/preferences/email", url: "/users/" + (this.get('username_lower')) + "/preferences/email",
type: 'PUT', type: 'PUT',
data: { data: {
@ -121,7 +121,7 @@ Discourse.User = Discourse.Model.extend({
**/ **/
save: function(finished) { save: function(finished) {
var _this = this; var _this = this;
jQuery.ajax("/users/" + this.get('username').toLowerCase(), { $.ajax("/users/" + this.get('username').toLowerCase(), {
data: this.getProperties('auto_track_topics_after_msecs', data: this.getProperties('auto_track_topics_after_msecs',
'bio_raw', 'bio_raw',
'website', 'website',
@ -153,7 +153,7 @@ Discourse.User = Discourse.Model.extend({
changePassword: function(callback) { changePassword: function(callback) {
var good; var good;
good = false; good = false;
jQuery.ajax({ $.ajax({
url: '/session/forgot_password', url: '/session/forgot_password',
dataType: 'json', dataType: 'json',
data: { data: {
@ -199,7 +199,7 @@ Discourse.User = Discourse.Model.extend({
var stream, var stream,
_this = this; _this = this;
stream = this.get('stream'); stream = this.get('stream');
jQuery.ajax({ $.ajax({
url: "/user_actions/" + id + ".json", url: "/user_actions/" + id + ".json",
dataType: 'json', dataType: 'json',
cache: 'false', cache: 'false',
@ -241,7 +241,7 @@ Discourse.User = Discourse.Model.extend({
url += "&filter=" + (this.get('streamFilter')); url += "&filter=" + (this.get('streamFilter'));
} }
return jQuery.ajax({ return $.ajax({
url: url, url: url,
dataType: 'json', dataType: 'json',
cache: 'false', cache: 'false',
@ -362,7 +362,7 @@ Discourse.User.reopenClass({
@param {String} email An email address to check @param {String} email An email address to check
**/ **/
checkUsername: function(username, email) { checkUsername: function(username, email) {
return jQuery.ajax({ return $.ajax({
url: '/users/check_username', url: '/users/check_username',
type: 'GET', type: 'GET',
data: { data: {
@ -465,7 +465,7 @@ Discourse.User.reopenClass({
@returns Result of ajax call @returns Result of ajax call
**/ **/
createAccount: function(name, email, password, username, passwordConfirm, challenge) { createAccount: function(name, email, password, username, passwordConfirm, challenge) {
return jQuery.ajax({ return $.ajax({
url: '/users', url: '/users',
dataType: 'json', dataType: 'json',
data: { data: {

View file

@ -2,10 +2,20 @@
<div class='contents'> <div class='contents'>
<div id='new-user-education' style='display: none'> <div id='new-user-education' class='composer-popup' style='display: none'>
<a href='#' {{action closeEducation target="view"}} class='close'>{{i18n ok}}</a> <a href='#' {{action closeEducation}} class='close'>{{i18n ok}}</a>
{{{controller.educationContents}}}
</div>
{{{view.educationContents}}} <div id='similar-topics' class='composer-popup' style='display: none'>
<a href='#' {{action closeSimilar}} class='close'>{{i18n ok}}</a>
<h3>{{i18n composer.similar_topics}}<h3>
<ul class='topics'>
{{#each controller.similarTopics}}
<li>{{{topicLink this}}}</li>
{{/each}}
</ul>
</div> </div>
<div class='control'> <div class='control'>

View file

@ -19,36 +19,32 @@ Discourse.ComposerView = Discourse.View.extend({
'content.creatingTopic:topic', 'content.creatingTopic:topic',
'content.showPreview', 'content.showPreview',
'content.hidePreview'], 'content.hidePreview'],
educationClosed: null,
composeState: (function() { composeState: function() {
var state; var state = this.get('content.composeState');
state = this.get('content.composeState'); if (state) return state;
if (!state) { return Discourse.Composer.CLOSED;
state = Discourse.Composer.CLOSED; }.property('content.composeState'),
}
return state;
}).property('content.composeState'),
draftStatus: (function() { draftStatus: function() {
return this.$('.saving-draft').text(this.get('content.draftStatus') || ""); this.$('.saving-draft').text(this.get('content.draftStatus') || "");
}).observes('content.draftStatus'), }.observes('content.draftStatus'),
// Disable fields when we're loading // Disable fields when we're loading
loadingChanged: (function() { loadingChanged: function() {
if (this.get('loading')) { if (this.get('loading')) {
$('#wmd-input, #reply-title').prop('disabled', 'disabled'); $('#wmd-input, #reply-title').prop('disabled', 'disabled');
} else { } else {
$('#wmd-input, #reply-title').prop('disabled', ''); $('#wmd-input, #reply-title').prop('disabled', '');
} }
}).observes('loading'), }.observes('loading'),
postMade: (function() { postMade: function() {
if (this.present('controller.createdPost')) return 'created-post'; if (this.present('controller.createdPost')) return 'created-post';
return null; return null;
}).property('content.createdPost'), }.property('content.createdPost'),
observeReplyChanges: (function() { observeReplyChanges: function() {
var _this = this; var _this = this;
if (this.get('content.hidePreview')) return; if (this.get('content.hidePreview')) return;
Ember.run.next(null, function() { Ember.run.next(null, function() {
@ -65,89 +61,70 @@ Discourse.ComposerView = Discourse.View.extend({
} }
} }
}); });
}).observes('content.reply', 'content.hidePreview'), }.observes('content.reply', 'content.hidePreview'),
closeEducation: function() { newUserEducationVisibilityChanged: function() {
this.set('educationClosed', true); var $panel = $('#new-user-education');
return false; if (this.get('controller.newUserEducationVisible')) {
}, $panel.slideDown('fast');
fetchNewUserEducation: (function() {
// If creating a topic, use topic_count, otherwise post_count
var count, educationKey,
_this = this;
count = this.get('content.creatingTopic') ? Discourse.get('currentUser.topic_count') : Discourse.get('currentUser.reply_count');
if (count >= Discourse.SiteSettings.educate_until_posts) {
this.set('educationClosed', true);
this.set('educationContents', '');
return;
}
if (!this.get('controller.hasReply')) {
return;
}
this.set('educationClosed', false);
// If visible update the text
educationKey = this.get('content.creatingTopic') ? 'new-topic' : 'new-reply';
return $.get("/education/" + educationKey).then(function(result) {
return _this.set('educationContents', result);
});
}).observes('controller.hasReply', 'content.creatingTopic', 'Discourse.currentUser.reply_count'),
newUserEducationVisible: (function() {
if (!this.get('educationContents')) return false;
if (this.get('content.composeState') !== Discourse.Composer.OPEN) return false;
if (!this.present('content.reply')) return false;
if (this.get('educationClosed')) return false;
return true;
}).property('content.composeState', 'content.reply', 'educationClosed', 'educationContents'),
newUserEducationVisibilityChanged: (function() {
var $panel;
$panel = $('#new-user-education');
if (this.get('newUserEducationVisible')) {
return $panel.slideDown('fast');
} else { } else {
return $panel.slideUp('fast'); $panel.slideUp('fast')
} }
}).observes('newUserEducationVisible'), }.observes('controller.newUserEducationVisible'),
moveNewUserEducation: function(sizePx) { similarVisibilityChanged: function() {
$('#new-user-education').css('bottom', sizePx); var $panel = $('#similar-topics');
if (this.get('controller.similarVisible')) {
$panel.slideDown('fast');
} else {
$panel.slideUp('fast')
}
}.observes('controller.similarVisible'),
movePanels: function(sizePx) {
$('.composer-popup').css('bottom', sizePx);
}, },
resize: (function() { resize: function() {
// this still needs to wait on animations, need a clean way to do that // this still needs to wait on animations, need a clean way to do that
var _this = this;
return Em.run.next(null, function() { return Em.run.next(null, function() {
var h, replyControl, sizePx; var replyControl = $('#reply-control');
replyControl = $('#reply-control'); var h = replyControl.height() || 0;
h = replyControl.height() || 0; var sizePx = "" + h + "px";
sizePx = "" + h + "px";
$('.topic-area').css('padding-bottom', sizePx); $('.topic-area').css('padding-bottom', sizePx);
return $('#new-user-education').css('bottom', sizePx); $('.composer-popup').css('bottom', sizePx);
}); });
}).observes('content.composeState'), }.observes('content.composeState'),
keyUp: function(e) { keyUp: function(e) {
var controller; var controller = this.get('controller');
controller = this.get('controller');
controller.checkReplyLength(); controller.checkReplyLength();
var lastKeyUp = new Date();
this.set('lastKeyUp', lastKeyUp);
// One second from now, check to see if the last key was hit when
// we recorded it. If it was, the user paused typing.
var composerView = this;
Em.run.later(function() {
if (lastKeyUp !== composerView.get('lastKeyUp')) return;
// Search for similar topics if the user pauses typing
controller.findSimilarTopics();
}, 1000);
// If the user hit ESC
if (e.which === 27) controller.hitEsc(); if (e.which === 27) controller.hitEsc();
}, },
didInsertElement: function() { didInsertElement: function() {
var replyControl; var replyControl = $('#reply-control');
replyControl = $('#reply-control'); replyControl.DivResizer({ resize: this.resize, onDrag: this.movePanels });
replyControl.DivResizer({
resize: this.resize,
onDrag: this.moveNewUserEducation
});
Discourse.TransitionHelper.after(replyControl, this.resize); Discourse.TransitionHelper.after(replyControl, this.resize);
}, },
click: function() { click: function() {
return this.get('controller').click(); this.get('controller').openIfDraft();
}, },
// Called after the preview renders. Debounced for performance // Called after the preview renders. Debounced for performance
@ -229,7 +206,7 @@ Discourse.ComposerView = Discourse.View.extend({
}); });
}, },
onChangeItems: function(items) { onChangeItems: function(items) {
items = jQuery.map(items, function(i) { items = $.map(items, function(i) {
if (i.username) { if (i.username) {
return i.username; return i.username;
} else { } else {
@ -243,9 +220,7 @@ Discourse.ComposerView = Discourse.View.extend({
transformComplete: transformTemplate, transformComplete: transformTemplate,
reverseTransform: function(i) { reverseTransform: function(i) {
return { return { username: i };
username: i
};
} }
}); });

View file

@ -58,21 +58,23 @@ Discourse.ListTopicsView = Discourse.View.extend(Discourse.Scrolling, {
}, },
loadMore: function() { loadMore: function() {
var _this = this; if (this.get('loading')) return;
if (this.get('loading')) {
return;
}
this.set('loading', true); this.set('loading', true);
return this.get('controller.content').loadMoreTopics().then(function(hasMoreResults) {
_this.set('loadedMore', true); var listTopicsView = this;
_this.set('loading', false); var promise = this.get('controller.content').loadMoreTopics();
Em.run.next(function() { if (promise) {
return _this.saveScrollPos(); promise.then(function(hasMoreResults) {
listTopicsView.set('loadedMore', true);
listTopicsView.set('loading', false);
Em.run.next(function() { listTopicsView.saveScrollPos(); });
if (!hasMoreResults) {
listTopicsView.get('eyeline').flushRest();
}
}); });
if (!hasMoreResults) { } else {
return _this.get('eyeline').flushRest(); this.set('loading', false);
} }
});
}, },
// Remember where we were scrolled to // Remember where we were scrolled to

View file

@ -12,28 +12,27 @@ Discourse.HistoryView = Discourse.View.extend({
modalClass: 'history-modal', modalClass: 'history-modal',
loadSide: function(side) { loadSide: function(side) {
var orig, version,
_this = this;
if (this.get("version" + side)) { if (this.get("version" + side)) {
orig = this.get('originalPost'); var orig = this.get('originalPost');
version = this.get("version" + side + ".number"); var version = this.get("version" + side + ".number");
if (version === orig.get('version')) { if (version === orig.get('version')) {
return this.set("post" + side, orig); this.set("post" + side, orig);
} else { } else {
return Discourse.Post.loadVersion(orig.get('id'), version, function(post) { var historyView = this;
return _this.set("post" + side, post); Discourse.Post.loadVersion(orig.get('id'), version).then(function(post) {
historyView.set("post" + side, post);
}); });
} }
} }
}, },
changedLeftVersion: (function() { changedLeftVersion: function() {
return this.loadSide("Left"); this.loadSide("Left");
}).observes('versionLeft'), }.observes('versionLeft'),
changedRightVersion: (function() { changedRightVersion: function() {
return this.loadSide("Right"); this.loadSide("Right");
}).observes('versionRight'), }.observes('versionRight'),
didInsertElement: function() { didInsertElement: function() {
var _this = this; var _this = this;

View file

@ -109,9 +109,8 @@ Discourse.PostView = Discourse.View.extend({
// Toggle visibility of parent post // Toggle visibility of parent post
toggleParent: function(e) { toggleParent: function(e) {
var $parent, post, var postView = this;
_this = this; var $parent = this.$('.parent-post');
$parent = this.$('.parent-post');
if (this.get('parentPost')) { if (this.get('parentPost')) {
$('nav', $parent).removeClass('toggled'); $('nav', $parent).removeClass('toggled');
// Don't animate on touch // Don't animate on touch
@ -119,19 +118,18 @@ Discourse.PostView = Discourse.View.extend({
$parent.hide(); $parent.hide();
this.set('parentPost', null); this.set('parentPost', null);
} else { } else {
$parent.slideUp(function() { $parent.slideUp(function() { postView.set('parentPost', null); });
return _this.set('parentPost', null);
});
} }
} else { } else {
post = this.get('post'); var post = this.get('post');
this.set('loadingParent', true); this.set('loadingParent', true);
$('nav', $parent).addClass('toggled'); $('nav', $parent).addClass('toggled');
Discourse.Post.loadByPostNumber(post.get('topic_id'), post.get('reply_to_post_number'), function(result) {
_this.set('loadingParent', false); Discourse.Post.loadByPostNumber(post.get('topic_id'), post.get('reply_to_post_number')).then(function(result) {
postView.set('loadingParent', false);
// Give the post a reference back to the topic // Give the post a reference back to the topic
result.topic = _this.get('post.topic'); result.topic = postView.get('post.topic');
return _this.set('parentPost', result); postView.set('parentPost', result);
}); });
} }
return false; return false;

View file

@ -235,7 +235,7 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
if (this.loading) return; if (this.loading) return;
this.set('loading', true); this.set('loading', true);
this.set('loadingAbove', true); this.set('loadingAbove', true);
opts = jQuery.extend({ opts = $.extend({
postsBefore: post.get('post_number') postsBefore: post.get('post_number')
}, this.get('controller.postFilters')); }, this.get('controller.postFilters'));
@ -303,9 +303,7 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
if (this.topic.posts.last().post_number !== post.post_number) return; if (this.topic.posts.last().post_number !== post.post_number) return;
this.set('loadingBelow', true); this.set('loadingBelow', true);
this.set('loading', true); this.set('loading', true);
opts = jQuery.extend({ opts = $.extend({ postsAfter: post.get('post_number') }, this.get('controller.postFilters'));
postsAfter: post.get('post_number')
}, this.get('controller.postFilters'));
return Discourse.Topic.find(this.get('topic.id'), opts).then(function(result) { return Discourse.Topic.find(this.get('topic.id'), opts).then(function(result) {
var suggested; var suggested;
if (result.at_bottom || result.posts.length === 0) { if (result.at_bottom || result.posts.length === 0) {

View file

@ -28,34 +28,34 @@ PreloadStore = {
@method get @method get
@param {String} key the key to look up the object with @param {String} key the key to look up the object with
@param {function} finder a function to find the object with @param {function} finder a function to find the object with
@returns {Promise} a promise that will eventually be the object we want. @returns {Ember.Deferred} a promise that will eventually be the object we want.
**/ **/
get: function(key, finder) { get: function(key, finder) {
var promise = new RSVP.Promise(); var preloadStore = this;
return Ember.Deferred.promise(function(promise) {
if (this.data[key]) { if (preloadStore.data[key]) {
promise.resolve(this.data[key]); promise.resolve(preloadStore.data[key]);
delete this.data[key]; delete preloadStore.data[key];
} else {
if (finder) {
var result = finder();
// If the finder returns a promise, we support that too
if (result.then) {
result.then(function(result) {
return promise.resolve(result);
}, function(result) {
return promise.reject(result);
});
} else {
promise.resolve(result);
}
} else { } else {
promise.resolve(null);
if (finder) {
var result = finder();
// If the finder returns a promise, we support that too
if (result.then) {
result.then(function(result) {
return promise.resolve(result);
}, function(result) {
return promise.reject(result);
});
} else {
promise.resolve(result);
}
} else {
promise.resolve(null);
}
} }
} });
return promise;
}, },
/** /**

View file

@ -4,7 +4,7 @@
@import "foundation/mixins"; @import "foundation/mixins";
#new-user-education { .composer-popup {
@include box-shadow(3px 3px 3px rgba($black, 0.14)); @include box-shadow(3px 3px 3px rgba($black, 0.14));
@ -17,10 +17,21 @@
} }
background-color: lighten($yellow, 40%); background-color: lighten($yellow, 40%);
border: 1px solid $yellow; border: 1px solid darken($yellow, 5%);
padding: 10px; padding: 10px;
width: 600px; width: 600px;
position: absolute; position: absolute;
ul.topics {
list-style: none;
margin: 0;
padding: 0;
li {
font-weight: normal;
margin-top: 3px;
}
}
} }
#reply-control { #reply-control {

View file

@ -59,6 +59,17 @@ class TopicsController < ApplicationController
render nothing: true render nothing: true
end end
def similar_to
requires_parameters(:title, :raw)
title, raw = params[:title], params[:raw]
raise Discourse::InvalidParameters.new(:title) if title.length < SiteSetting.min_title_similar_length
raise Discourse::InvalidParameters.new(:raw) if raw.length < SiteSetting.min_body_similar_length
topics = Topic.similar_to(title, raw)
render_serialized(topics, BasicTopicSerializer)
end
def status def status
requires_parameters(:status, :enabled) requires_parameters(:status, :enabled)

View file

@ -34,6 +34,8 @@ class SiteSetting < ActiveRecord::Base
client_setting(:supress_reply_directly_below, true) client_setting(:supress_reply_directly_below, true)
client_setting(:email_domains_blacklist, 'mailinator.com') client_setting(:email_domains_blacklist, 'mailinator.com')
client_setting(:version_checks, true) client_setting(:version_checks, true)
client_setting(:min_title_similar_length, 10)
client_setting(:min_body_similar_length, 15)
# settings only available server side # settings only available server side
setting(:auto_track_topics_after, 300000) setting(:auto_track_topics_after, 300000)

View file

@ -213,8 +213,22 @@ class Topic < ActiveRecord::Base
id).to_a id).to_a
end end
def update_status(property, status, user) # Search for similar topics
def self.similar_to(title, raw)
return [] unless title.present?
return [] unless raw.present?
# For now, we only match on title. We'll probably add body later on, hence the API hook
Topic.select(sanitize_sql_array(["topics.*, similarity(topics.title, :title) AS similarity", title: title]))
.visible
.where(closed: false, archived: false)
.listable_topics
.limit(5)
.order('similarity desc')
.all
end
def update_status(property, status, user)
Topic.transaction do Topic.transaction do
# Special case: if it's pinned, update that # Special case: if it's pinned, update that
@ -592,7 +606,6 @@ class Topic < ActiveRecord::Base
def clear_pin_for(user) def clear_pin_for(user)
return unless user.present? return unless user.present?
TopicUser.change(user.id, id, cleared_pinned_at: Time.now) TopicUser.change(user.id, id, cleared_pinned_at: Time.now)
end end

View file

@ -1,58 +1,9 @@
require_dependency 'age_words' require_dependency 'age_words'
# The most basic attributes of a topic that we need to create a link for it.
class BasicTopicSerializer < ApplicationSerializer class BasicTopicSerializer < ApplicationSerializer
include ActionView::Helpers include ActionView::Helpers
attributes :id, attributes :id, :fancy_title, :slug
:title,
:fancy_title,
:reply_count,
:posts_count,
:highest_post_number,
:image_url,
:created_at,
:last_posted_at,
:age,
:unseen,
:last_read_post_number,
:unread,
:new_posts
def age
AgeWords.age_words(Time.now - (object.created_at || Time.now))
end
def seen
object.user_data.present?
end
def unseen
return false if scope.blank?
return false if scope.user.blank?
return false if object.user_data.present?
return false if object.created_at < scope.user.treat_as_new_topic_start_date
true
end
def last_read_post_number
object.user_data.last_read_post_number
end
alias :include_last_read_post_number? :seen
def unread
unread_helper.unread_posts
end
alias :include_unread? :seen
def new_posts
unread_helper.new_posts
end
alias :include_new_posts? :seen
protected
def unread_helper
@unread_helper ||= Unread.new(object, object.user_data)
end
end end

View file

@ -1,10 +1,6 @@
class CategoryTopicSerializer < BasicTopicSerializer class CategoryTopicSerializer < ListableTopicSerializer
attributes :slug,
:visible,
:closed,
:archived
attributes :visible, :closed, :archived
has_one :category has_one :category
end end

View file

@ -0,0 +1,56 @@
require_dependency 'age_words'
class ListableTopicSerializer < BasicTopicSerializer
include ActionView::Helpers
attributes :reply_count,
:posts_count,
:highest_post_number,
:image_url,
:created_at,
:last_posted_at,
:age,
:unseen,
:last_read_post_number,
:unread,
:new_posts,
:title
def age
AgeWords.age_words(Time.now - (object.created_at || Time.now))
end
def seen
object.user_data.present?
end
def unseen
return false if scope.blank?
return false if scope.user.blank?
return false if object.user_data.present?
return false if object.created_at < scope.user.treat_as_new_topic_start_date
true
end
def last_read_post_number
object.user_data.last_read_post_number
end
alias :include_last_read_post_number? :seen
def unread
unread_helper.unread_posts
end
alias :include_unread? :seen
def new_posts
unread_helper.new_posts
end
alias :include_new_posts? :seen
protected
def unread_helper
@unread_helper ||= Unread.new(object, object.user_data)
end
end

View file

@ -1,6 +1,6 @@
class SuggestedTopicSerializer < BasicTopicSerializer class SuggestedTopicSerializer < ListableTopicSerializer
attributes :archetype, :slug, :like_count, :views, :last_post_age attributes :archetype, :like_count, :views, :last_post_age
has_one :category, embed: :objects has_one :category, embed: :objects
def last_post_age def last_post_age

View file

@ -1,6 +1,6 @@
require_dependency 'pinned_check' require_dependency 'pinned_check'
class TopicListItemSerializer < BasicTopicSerializer class TopicListItemSerializer < ListableTopicSerializer
attributes :views, attributes :views,
:like_count, :like_count,
@ -11,8 +11,7 @@ class TopicListItemSerializer < BasicTopicSerializer
:last_post_age, :last_post_age,
:starred, :starred,
:has_best_of, :has_best_of,
:archetype, :archetype
:slug
has_one :category has_one :category
has_many :posters, serializer: TopicPosterSerializer, embed: :objects has_many :posters, serializer: TopicPosterSerializer, embed: :objects

View file

@ -267,6 +267,7 @@ en:
saving_draft_tip: "saving" saving_draft_tip: "saving"
saved_draft_tip: "saved" saved_draft_tip: "saved"
saved_local_draft_tip: "saved locally" saved_local_draft_tip: "saved locally"
similar_topics: "Similar Topics"
min_length: min_length:
at_least: "enter at least {{n}} characters" at_least: "enter at least {{n}} characters"

View file

@ -408,6 +408,9 @@ en:
new_user_period_days: "How long a user is highlighted as being new, in days" new_user_period_days: "How long a user is highlighted as being new, in days"
title_fancy_entities: "Convert fancy HTML entities in topic titles" title_fancy_entities: "Convert fancy HTML entities in topic titles"
min_title_similar_length: "The minimum length of a title before it will be checked for similar topics"
min_body_similar_length: "The minimum length of a post's body before it will be checked for similar topics"
notification_types: notification_types:
mentioned: "%{display_username} mentioned you in %{link}" mentioned: "%{display_username} mentioned you in %{link}"
liked: "%{display_username} liked your post in %{link}" liked: "%{display_username} liked your post in %{link}"

View file

@ -164,7 +164,8 @@ Discourse::Application.routes.draw do
delete 't/:id' => 'topics#destroy' delete 't/:id' => 'topics#destroy'
put 't/:id' => 'topics#update' put 't/:id' => 'topics#update'
post 't' => 'topics#create' post 't' => 'topics#create'
post 'topics/timings' => 'topics#timings' post 'topics/timings'
get 'topics/similar_to'
# Legacy route for old avatars # Legacy route for old avatars
get 'threads/:topic_id/:post_number/avatar' => 'topics#avatar', :constraints => {:topic_id => /\d+/, :post_number => /\d+/} get 'threads/:topic_id/:post_number/avatar' => 'topics#avatar', :constraints => {:topic_id => /\d+/, :post_number => /\d+/}

View file

@ -0,0 +1,9 @@
class EnableTrigramSupport < ActiveRecord::Migration
def up
execute "CREATE EXTENSION IF NOT EXISTS pg_trgm"
end
def down
execute "DROP EXTENSION pg_trgm"
end
end

View file

@ -70,10 +70,39 @@ describe TopicsController do
end end
end end
end
context 'similar_to' do
let(:title) { 'this title is long enough to search for' }
let(:raw) { 'this body is long enough to search for' }
it "requires a title" do
-> { xhr :get, :similar_to, raw: raw }.should raise_error(Discourse::InvalidParameters)
end
it "requires a raw body" do
-> { xhr :get, :similar_to, title: title }.should raise_error(Discourse::InvalidParameters)
end
it "raises an error if the title length is below the minimum" do
SiteSetting.stubs(:min_title_similar_length).returns(100)
-> { xhr :get, :similar_to, title: title, raw: raw }.should raise_error(Discourse::InvalidParameters)
end
it "raises an error if the body length is below the minimum" do
SiteSetting.stubs(:min_body_similar_length).returns(100)
-> { xhr :get, :similar_to, title: title, raw: raw }.should raise_error(Discourse::InvalidParameters)
end
it "delegates to Topic.similar_to" do
Topic.expects(:similar_to).with(title, raw).returns([Fabricate(:topic)])
xhr :get, :similar_to, title: title, raw: raw
end
end end
context 'clear_pin' do context 'clear_pin' do
it 'needs you to be logged in' do it 'needs you to be logged in' do
lambda { xhr :put, :clear_pin, topic_id: 1 }.should raise_error(Discourse::NotLoggedIn) lambda { xhr :put, :clear_pin, topic_id: 1 }.should raise_error(Discourse::NotLoggedIn)

View file

@ -68,9 +68,7 @@ describe("PreloadStore", function() {
var done, finder, storeResult; var done, finder, storeResult;
done = storeResult = null; done = storeResult = null;
finder = function() { finder = function() {
var promise = new RSVP.Promise(); return Ember.Deferred.promise(function(promise) { promise.resolve('evil'); });
promise.resolve('evil');
return promise;
}; };
PreloadStore.get('joker', finder).then(function(result) { PreloadStore.get('joker', finder).then(function(result) {
done = true; done = true;
@ -86,9 +84,7 @@ describe("PreloadStore", function() {
var done, finder, storeResult; var done, finder, storeResult;
done = storeResult = null; done = storeResult = null;
finder = function() { finder = function() {
var promise = new RSVP.Promise(); return Ember.Deferred.promise(function(promise) { promise.reject('evil'); });
promise.reject('evil');
return promise;
}; };
PreloadStore.get('joker', finder).then(null, function(rejectedResult) { PreloadStore.get('joker', finder).then(null, function(rejectedResult) {
done = true; done = true;

View file

@ -155,6 +155,24 @@ describe Topic do
end end
context 'similar_to' do
it 'returns blank with nil params' do
Topic.similar_to(nil, nil).should be_blank
end
context 'with a similar topic' do
let!(:topic) { Fabricate(:topic, title: "Evil trout is the dude who posted this topic") }
it 'returns the similar topic if the title is similar' do
Topic.similar_to("has evil trout made any topics?", "i am wondering has evil trout made any topics?").should == [topic]
end
end
end
context 'message bus' do context 'message bus' do
it 'calls the message bus observer after create' do it 'calls the message bus observer after create' do
MessageBusObserver.any_instance.expects(:after_create_topic).with(instance_of(Topic)) MessageBusObserver.any_instance.expects(:after_create_topic).with(instance_of(Topic))