453 lines
13 KiB
JavaScript
453 lines
13 KiB
JavaScript
/**
|
|
A data model representing a Topic
|
|
|
|
@class Topic
|
|
@extends Discourse.Model
|
|
@namespace Discourse
|
|
@module Discourse
|
|
**/
|
|
Discourse.Topic = Discourse.Model.extend({
|
|
categoriesBinding: 'Discourse.site.categories',
|
|
|
|
fewParticipants: (function() {
|
|
if (!this.present('participants')) return null;
|
|
return this.get('participants').slice(0, 3);
|
|
}).property('participants'),
|
|
|
|
canConvertToRegular: (function() {
|
|
var a = this.get('archetype');
|
|
return a !== 'regular' && a !== 'private_message';
|
|
}).property('archetype'),
|
|
|
|
convertArchetype: function(archetype) {
|
|
var a;
|
|
a = this.get('archetype');
|
|
if (a !== 'regular' && a !== 'private_message') {
|
|
this.set('archetype', 'regular');
|
|
return $.post(this.get('url'), {
|
|
_method: 'put',
|
|
archetype: 'regular'
|
|
});
|
|
}
|
|
},
|
|
|
|
category: (function() {
|
|
if (this.get('categories')) {
|
|
return this.get('categories').findProperty('name', this.get('categoryName'));
|
|
}
|
|
}).property('categoryName', 'categories'),
|
|
|
|
url: (function() {
|
|
var slug = this.get('slug');
|
|
if (slug.isBlank()) {
|
|
slug = "topic";
|
|
}
|
|
return "/t/" + slug + "/" + (this.get('id'));
|
|
}).property('id', 'slug'),
|
|
|
|
// Helper to build a Url with a post number
|
|
urlForPostNumber: function(postNumber) {
|
|
var url;
|
|
url = this.get('url');
|
|
if (postNumber && (postNumber > 1)) {
|
|
url += "/" + postNumber;
|
|
}
|
|
return url;
|
|
},
|
|
|
|
lastReadUrl: (function() {
|
|
return this.urlForPostNumber(this.get('last_read_post_number'));
|
|
}).property('url', 'last_read_post_number'),
|
|
|
|
lastPostUrl: (function() {
|
|
return this.urlForPostNumber(this.get('highest_post_number'));
|
|
}).property('url', 'highest_post_number'),
|
|
|
|
// The last post in the topic
|
|
lastPost: function() {
|
|
return this.get('posts').last();
|
|
},
|
|
|
|
postsChanged: (function() {
|
|
var last, posts;
|
|
posts = this.get('posts');
|
|
last = posts.last();
|
|
if (!(last && last.set && !last.lastPost)) return;
|
|
posts.each(function(p) {
|
|
if (p.lastPost) return p.set('lastPost', false);
|
|
});
|
|
last.set('lastPost', true);
|
|
return true;
|
|
}).observes('posts.@each', 'posts'),
|
|
|
|
// The amount of new posts to display. It might be different than what the server
|
|
// tells us if we are still asynchronously flushing our "recently read" data.
|
|
// So take what the browser has seen into consideration.
|
|
displayNewPosts: (function() {
|
|
var delta, highestSeen, result;
|
|
if (highestSeen = Discourse.get('highestSeenByTopic')[this.get('id')]) {
|
|
delta = highestSeen - this.get('last_read_post_number');
|
|
if (delta > 0) {
|
|
result = this.get('new_posts') - delta;
|
|
if (result < 0) {
|
|
result = 0;
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
return this.get('new_posts');
|
|
}).property('new_posts', 'id'),
|
|
|
|
// The coldmap class for the age of the topic
|
|
ageCold: (function() {
|
|
var createdAt, createdAtDays, daysSinceEpoch, lastPost, nowDays;
|
|
if (!(lastPost = this.get('last_posted_at'))) return;
|
|
if (!(createdAt = this.get('created_at'))) return;
|
|
daysSinceEpoch = function(dt) {
|
|
// 1000 * 60 * 60 * 24 = days since epoch
|
|
return dt.getTime() / 86400000;
|
|
};
|
|
|
|
// Show heat on age
|
|
nowDays = daysSinceEpoch(new Date());
|
|
createdAtDays = daysSinceEpoch(new Date(createdAt));
|
|
if (daysSinceEpoch(new Date(lastPost)) > nowDays - 90) {
|
|
if (createdAtDays < nowDays - 60) return 'coldmap-high';
|
|
if (createdAtDays < nowDays - 30) return 'coldmap-med';
|
|
if (createdAtDays < nowDays - 14) return 'coldmap-low';
|
|
}
|
|
return null;
|
|
}).property('age', 'created_at'),
|
|
|
|
archetypeObject: (function() {
|
|
return Discourse.get('site.archetypes').findProperty('id', this.get('archetype'));
|
|
}).property('archetype'),
|
|
|
|
isPrivateMessage: (function() {
|
|
return this.get('archetype') === 'private_message';
|
|
}).property('archetype'),
|
|
|
|
// Does this topic only have a single post?
|
|
singlePost: (function() {
|
|
return this.get('posts_count') === 1;
|
|
}).property('posts_count'),
|
|
|
|
toggleStatus: function(property) {
|
|
this.toggleProperty(property);
|
|
return $.post("" + (this.get('url')) + "/status", {
|
|
_method: 'put',
|
|
status: property,
|
|
enabled: this.get(property) ? 'true' : 'false'
|
|
});
|
|
},
|
|
|
|
toggleStar: function() {
|
|
var topic = this;
|
|
topic.toggleProperty('starred');
|
|
return $.ajax({
|
|
url: "" + (this.get('url')) + "/star",
|
|
type: 'PUT',
|
|
data: { starred: topic.get('starred') ? true : false },
|
|
error: function(error) {
|
|
topic.toggleProperty('starred');
|
|
var errors = $.parseJSON(error.responseText).errors;
|
|
return bootbox.alert(errors[0]);
|
|
}
|
|
});
|
|
},
|
|
|
|
// Save any changes we've made to the model
|
|
save: function() {
|
|
// Don't save unless we can
|
|
if (!this.get('can_edit')) return;
|
|
return $.post(this.get('url'), {
|
|
_method: 'put',
|
|
title: this.get('title'),
|
|
category: this.get('category.name')
|
|
});
|
|
},
|
|
|
|
// Reset our read data for this topic
|
|
resetRead: function(callback) {
|
|
return $.ajax("/t/" + (this.get('id')) + "/timings", {
|
|
type: 'DELETE',
|
|
success: function() {
|
|
return typeof callback === "function" ? callback() : void 0;
|
|
}
|
|
});
|
|
},
|
|
|
|
// Invite a user to this topic
|
|
inviteUser: function(user) {
|
|
return $.ajax({
|
|
type: 'POST',
|
|
url: "/t/" + (this.get('id')) + "/invite",
|
|
data: {
|
|
user: user
|
|
}
|
|
});
|
|
},
|
|
|
|
// Delete this topic
|
|
"delete": function(callback) {
|
|
return $.ajax("/t/" + (this.get('id')), {
|
|
type: 'DELETE',
|
|
success: function() {
|
|
return typeof callback === "function" ? callback() : void 0;
|
|
}
|
|
});
|
|
},
|
|
|
|
// Load the posts for this topic
|
|
loadPosts: function(opts) {
|
|
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
|
|
if (Discourse.TopicView.scrollTo(this.get('id'), opts.nearPost)) return;
|
|
|
|
// 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;
|
|
result.posts.each(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;
|
|
result.posts.each(function(p) {
|
|
var post;
|
|
p.scrollToAfterInsert = opts.nearPost;
|
|
post = Discourse.Post.create(p);
|
|
post.set('topic', topic);
|
|
topic.get('posts').pushObject(post);
|
|
lastPost = post;
|
|
});
|
|
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
|
|
Discourse.Topic.find(this.get('id'), {
|
|
nearPost: opts.nearPost,
|
|
bestOf: opts.bestOf,
|
|
trackVisit: opts.trackVisit
|
|
}).then(afterTopicLoaded, errorLoadingTopic);
|
|
},
|
|
|
|
notificationReasonText: (function() {
|
|
var locale_string;
|
|
locale_string = "topic.notifications.reasons." + this.notification_level;
|
|
if (typeof this.notifications_reason_id === 'number') {
|
|
locale_string += "_" + this.notifications_reason_id;
|
|
}
|
|
return Em.String.i18n(locale_string, { username: Discourse.currentUser.username.toLowerCase() });
|
|
}).property('notifications_reason_id'),
|
|
|
|
updateNotifications: function(v) {
|
|
this.set('notification_level', v);
|
|
this.set('notifications_reason_id', null);
|
|
return $.ajax({
|
|
url: "/t/" + (this.get('id')) + "/notifications",
|
|
type: 'POST',
|
|
data: {
|
|
notification_level: v
|
|
}
|
|
});
|
|
},
|
|
|
|
// use to add post to topics protecting from dupes
|
|
pushPosts: function(newPosts) {
|
|
var map, posts;
|
|
map = {};
|
|
posts = this.get('posts');
|
|
posts.each(function(p) {
|
|
map["" + p.post_number] = true;
|
|
});
|
|
return newPosts.each(function(p) {
|
|
if (!map[p.get('post_number')]) {
|
|
return posts.pushObject(p);
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
Clears the pin from a topic for the currentUser
|
|
|
|
@method clearPin
|
|
**/
|
|
clearPin: function() {
|
|
|
|
var topic = this;
|
|
|
|
// Clear the pin optimistically from the object
|
|
topic.set('pinned', false);
|
|
|
|
$.ajax("/t/" + this.get('id') + "/clear-pin", {
|
|
type: 'PUT',
|
|
error: function() {
|
|
// On error, put the pin back
|
|
topic.set('pinned', true);
|
|
}
|
|
});
|
|
},
|
|
|
|
// Is the reply to a post directly below it?
|
|
isReplyDirectlyBelow: function(post) {
|
|
var postBelow, posts;
|
|
posts = this.get('posts');
|
|
if (!posts) return;
|
|
|
|
postBelow = posts[posts.indexOf(post) + 1];
|
|
|
|
// If the post directly below's reply_to_post_number is our post number, it's
|
|
// considered directly below.
|
|
return (postBelow ? postBelow.get('reply_to_post_number') : void 0) === post.get('post_number');
|
|
}
|
|
});
|
|
|
|
Discourse.Topic.reopenClass({
|
|
NotificationLevel: {
|
|
WATCHING: 3,
|
|
TRACKING: 2,
|
|
REGULAR: 1,
|
|
MUTE: 0
|
|
},
|
|
|
|
// 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 = "/t/" + topicId;
|
|
|
|
if (opts.nearPost) {
|
|
url += "/" + opts.nearPost;
|
|
}
|
|
|
|
data = {};
|
|
if (opts.postsAfter) {
|
|
data.posts_after = opts.postsAfter;
|
|
}
|
|
if (opts.postsBefore) {
|
|
data.posts_before = opts.postsBefore;
|
|
}
|
|
if (opts.trackVisit) {
|
|
data.track_visit = true;
|
|
}
|
|
|
|
// Add username filters if we have them
|
|
if (opts.userFilters && opts.userFilters.length > 0) {
|
|
data.username_filters = [];
|
|
opts.userFilters.forEach(function(username) {
|
|
return data.username_filters.push(username);
|
|
});
|
|
}
|
|
|
|
// Add the best of filter if we have it
|
|
if (opts.bestOf === true) {
|
|
data.best_of = true;
|
|
}
|
|
|
|
// Check the preload store. If not, load it via JSON
|
|
promise = new RSVP.Promise();
|
|
PreloadStore.get("topic_" + topicId, function() {
|
|
return $.getJSON(url + ".json", data);
|
|
}).then(function(result) {
|
|
var first;
|
|
first = result.posts.first();
|
|
if (first && opts && opts.bestOf) {
|
|
first.bestOfFirst = true;
|
|
}
|
|
return promise.resolve(result);
|
|
}, function(result) {
|
|
return promise.reject(result);
|
|
});
|
|
return promise;
|
|
},
|
|
|
|
// Create a topic from posts
|
|
movePosts: function(topicId, title, postIds) {
|
|
return $.ajax("/t/" + topicId + "/move-posts", {
|
|
type: 'POST',
|
|
data: { title: title, post_ids: postIds }
|
|
});
|
|
},
|
|
|
|
create: function(obj, topicView) {
|
|
return Object.tap(this._super(obj), function(result) {
|
|
if (result.participants) {
|
|
result.participants = result.participants.map(function(u) {
|
|
return Discourse.User.create(u);
|
|
});
|
|
result.fewParticipants = Em.A();
|
|
return result.participants.each(function(p) {
|
|
if (result.fewParticipants.length >= 8) return false;
|
|
result.fewParticipants.pushObject(p);
|
|
return true;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
});
|
|
|
|
|