This repository has been archived on 2023-03-18. You can view files and clone it, but cannot push or open issues or pull requests.
osr-discourse-src/app/assets/javascripts/discourse/models/topic.js.es6
Dan Ungureanu fdb1d3404c
FEATURE: Add site setting to show more detailed 404 errors. (#8014)
If the setting is turned on, then the user will receive information
about the subject: if it was deleted or requires some special access to
a group (only if the group is public). Otherwise, the user will receive
a generic #404 error message. For now, this change affects only the
topics and categories controller.

This commit also tries to refactor some of the code related to error
handling. To make error pages more consistent (design-wise), the actual
error page will be rendered server-side.
2019-10-08 14:15:08 +03:00

782 lines
20 KiB
JavaScript

import { ajax } from "discourse/lib/ajax";
import { flushMap } from "discourse/models/store";
import RestModel from "discourse/models/rest";
import { propertyEqual, fmt } from "discourse/lib/computed";
import { longDate } from "discourse/lib/formatter";
import { isRTL } from "discourse/lib/text-direction";
import ActionSummary from "discourse/models/action-summary";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { censor } from "pretty-text/censored-words";
import { emojiUnescape } from "discourse/lib/text";
import PreloadStore from "preload-store";
import { userPath } from "discourse/lib/url";
import {
default as computed,
observes,
on
} from "ember-addons/ember-computed-decorators";
export function loadTopicView(topic, args) {
const data = _.merge({}, args);
const url = `${Discourse.getURL("/t/")}${topic.id}`;
const jsonUrl = (data.nearPost ? `${url}/${data.nearPost}` : url) + ".json";
delete data.nearPost;
delete data.__type;
delete data.store;
return PreloadStore.getAndRemove(`topic_${topic.id}`, () =>
ajax(jsonUrl, { data })
).then(json => {
topic.updateFromJson(json);
return json;
});
}
export const ID_CONSTRAINT = /^\d+$/;
const Topic = RestModel.extend({
message: null,
errorLoading: false,
@computed("last_read_post_number", "highest_post_number")
visited(lastReadPostNumber, highestPostNumber) {
// >= to handle case where there are deleted posts at the end of the topic
return lastReadPostNumber >= highestPostNumber;
},
@computed("posters.firstObject")
creator(poster) {
return poster && poster.user;
},
@computed("posters.[]")
lastPoster(posters) {
let user;
if (posters && posters.length > 0) {
const latest = posters.filter(
p => p.extras && p.extras.indexOf("latest") >= 0
)[0];
user = latest && latest.user;
}
return user || this.creator;
},
@computed("posters.[]", "participants.[]")
featuredUsers(posters, participants) {
let users = posters;
const maxUserCount = 5;
const posterCount = users.length;
if (this.isPrivateMessage && participants && posterCount < maxUserCount) {
let pushOffset = 0;
if (posterCount > 1) {
const lastUser = users[posterCount - 1];
if (lastUser.extras && lastUser.extras.includes("latest")) {
pushOffset = 1;
}
}
const poster_ids = posters.map(p => p.user && p.user.id).filter(id => id);
participants.some(p => {
if (!poster_ids.includes(p.user_id)) {
users.splice(users.length - pushOffset, 0, p);
if (users.length === maxUserCount) {
return true;
}
}
return false;
});
}
return users;
},
@computed("fancy_title")
fancyTitle(title) {
let fancyTitle = censor(
emojiUnescape(title || ""),
Discourse.Site.currentProp("censored_regexp")
);
if (Discourse.SiteSettings.support_mixed_text_direction) {
const titleDir = isRTL(title) ? "rtl" : "ltr";
return `<span dir="${titleDir}">${fancyTitle}</span>`;
}
return fancyTitle;
},
// returns createdAt if there's no bumped date
@computed("bumped_at", "createdAt")
bumpedAt(bumped_at, createdAt) {
if (bumped_at) {
return new Date(bumped_at);
} else {
return createdAt;
}
},
@computed("bumpedAt", "createdAt")
bumpedAtTitle(bumpedAt, createdAt) {
const firstPost = I18n.t("first_post");
const lastPost = I18n.t("last_post");
const createdAtDate = longDate(createdAt);
const bumpedAtDate = longDate(bumpedAt);
return `${firstPost}: ${createdAtDate}\n${lastPost}: ${bumpedAtDate}`;
},
@computed("created_at")
createdAt(created_at) {
return new Date(created_at);
},
@computed
postStream() {
return this.store.createRecord("postStream", {
id: this.id,
topic: this
});
},
@computed("tags")
visibleListTags(tags) {
if (!tags || !Discourse.SiteSettings.suppress_overlapping_tags_in_list) {
return tags;
}
const title = this.title;
const newTags = [];
tags.forEach(function(tag) {
if (title.toLowerCase().indexOf(tag) === -1) {
newTags.push(tag);
}
});
return newTags;
},
@computed("related_messages")
relatedMessages(relatedMessages) {
if (relatedMessages) {
const store = this.store;
return this.set(
"related_messages",
relatedMessages.map(st => store.createRecord("topic", st))
);
}
},
@computed("suggested_topics")
suggestedTopics(suggestedTopics) {
if (suggestedTopics) {
const store = this.store;
return this.set(
"suggested_topics",
suggestedTopics.map(st => store.createRecord("topic", st))
);
}
},
@computed("posts_count")
replyCount(postsCount) {
return postsCount - 1;
},
@computed
details() {
return this.store.createRecord("topicDetails", {
id: this.id,
topic: this
});
},
invisible: Ember.computed.not("visible"),
deleted: Ember.computed.notEmpty("deleted_at"),
@computed("id")
searchContext(id) {
return { type: "topic", id };
},
@on("init")
@observes("category_id")
_categoryIdChanged() {
this.set("category", Discourse.Category.findById(this.category_id));
},
@observes("categoryName")
_categoryNameChanged() {
const categoryName = this.categoryName;
let category;
if (categoryName) {
category = this.site.get("categories").findBy("name", categoryName);
}
this.set("category", category);
},
categoryClass: fmt("category.fullSlug", "category-%@"),
@computed("tags")
tagClasses(tags) {
return tags && tags.map(t => `tag-${t}`).join(" ");
},
@computed("url")
shareUrl(url) {
const user = Discourse.User.current();
const userQueryString = user ? `?u=${user.get("username_lower")}` : "";
return `${url}${userQueryString}`;
},
printUrl: fmt("url", "%@/print"),
@computed("id", "slug")
url(id, slug) {
slug = slug || "";
if (slug.trim().length === 0) {
slug = "topic";
}
return `${Discourse.getURL("/t/")}${slug}/${id}`;
},
// Helper to build a Url with a post number
urlForPostNumber(postNumber) {
let url = this.url;
if (postNumber && postNumber > 0) {
url += `/${postNumber}`;
}
return url;
},
@computed("new_posts", "unread")
totalUnread(newPosts, unread) {
const count = (unread || 0) + (newPosts || 0);
return count > 0 ? count : null;
},
@computed("last_read_post_number", "url")
lastReadUrl(lastReadPostNumber) {
return this.urlForPostNumber(lastReadPostNumber);
},
@computed("last_read_post_number", "highest_post_number", "url")
lastUnreadUrl(lastReadPostNumber, highestPostNumber) {
if (highestPostNumber <= lastReadPostNumber) {
if (this.get("category.navigate_to_first_post_after_read")) {
return this.urlForPostNumber(1);
} else {
return this.urlForPostNumber(lastReadPostNumber + 1);
}
} else {
return this.urlForPostNumber(lastReadPostNumber + 1);
}
},
@computed("highest_post_number", "url")
lastPostUrl(highestPostNumber) {
return this.urlForPostNumber(highestPostNumber);
},
@computed("url")
firstPostUrl() {
return this.urlForPostNumber(1);
},
@computed("url")
summaryUrl() {
const summaryQueryString = this.has_summary ? "?filter=summary" : "";
return `${this.urlForPostNumber(1)}${summaryQueryString}`;
},
@computed("last_poster.username")
lastPosterUrl(username) {
return userPath(username);
},
// 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.
@computed("new_posts", "id")
displayNewPosts(newPosts, id) {
const highestSeen = Discourse.Session.currentProp("highestSeenByTopic")[id];
if (highestSeen) {
const delta = highestSeen - this.last_read_post_number;
if (delta > 0) {
let result = newPosts - delta;
if (result < 0) {
result = 0;
}
return result;
}
}
return newPosts;
},
@computed("views")
viewsHeat(v) {
if (v >= Discourse.SiteSettings.topic_views_heat_high) {
return "heatmap-high";
}
if (v >= Discourse.SiteSettings.topic_views_heat_medium) {
return "heatmap-med";
}
if (v >= Discourse.SiteSettings.topic_views_heat_low) {
return "heatmap-low";
}
return null;
},
@computed("archetype")
archetypeObject(archetype) {
return Discourse.Site.currentProp("archetypes").findBy("id", archetype);
},
isPrivateMessage: Ember.computed.equal("archetype", "private_message"),
isBanner: Ember.computed.equal("archetype", "banner"),
toggleStatus(property) {
this.toggleProperty(property);
return this.saveStatus(property, !!this.get(property));
},
saveStatus(property, value, until) {
if (property === "closed") {
this.incrementProperty("posts_count");
}
return ajax(`${this.url}/status`, {
type: "PUT",
data: {
status: property,
enabled: !!value,
until
}
});
},
makeBanner() {
return ajax(`/t/${this.id}/make-banner`, { type: "PUT" }).then(() =>
this.set("archetype", "banner")
);
},
removeBanner() {
return ajax(`/t/${this.id}/remove-banner`, {
type: "PUT"
}).then(() => this.set("archetype", "regular"));
},
toggleBookmark() {
if (this.bookmarking) {
return Ember.RSVP.Promise.resolve();
}
this.set("bookmarking", true);
const stream = this.postStream;
const posts = Ember.get(stream, "posts");
const firstPost =
posts && posts[0] && posts[0].get("post_number") === 1 && posts[0];
const bookmark = !this.bookmarked;
const path = bookmark ? "/bookmark" : "/remove_bookmarks";
const toggleBookmarkOnServer = () => {
return ajax(`/t/${this.id}${path}`, { type: "PUT" })
.then(() => {
this.toggleProperty("bookmarked");
if (bookmark && firstPost) {
firstPost.set("bookmarked", true);
return [firstPost.id];
}
if (!bookmark && posts) {
const updated = [];
posts.forEach(post => {
if (post.get("bookmarked")) {
post.set("bookmarked", false);
updated.push(post.get("id"));
}
});
return updated;
}
return [];
})
.catch(popupAjaxError)
.finally(() => this.set("bookmarking", false));
};
const unbookmarkedPosts = [];
if (!bookmark && posts) {
posts.forEach(
post => post.get("bookmarked") && unbookmarkedPosts.push(post)
);
}
return new Ember.RSVP.Promise(resolve => {
if (unbookmarkedPosts.length > 1) {
bootbox.confirm(
I18n.t("bookmarks.confirm_clear"),
I18n.t("no_value"),
I18n.t("yes_value"),
confirmed =>
confirmed ? toggleBookmarkOnServer().then(resolve) : resolve()
);
} else {
toggleBookmarkOnServer().then(resolve);
}
});
},
createGroupInvite(group) {
return ajax(`/t/${this.id}/invite-group`, {
type: "POST",
data: { group }
});
},
createInvite(user, group_names, custom_message) {
return ajax(`/t/${this.id}/invite`, {
type: "POST",
data: { user, group_names, custom_message }
});
},
generateInviteLink(email, groupNames, topicId) {
return ajax("/invites/link", {
type: "POST",
data: { email, group_names: groupNames, topic_id: topicId }
});
},
// Delete this topic
destroy(deleted_by) {
this.setProperties({
deleted_at: new Date(),
deleted_by: deleted_by,
"details.can_delete": false,
"details.can_recover": true
});
return ajax(`/t/${this.id}`, {
data: { context: window.location.pathname },
type: "DELETE"
});
},
// Recover this topic if deleted
recover() {
this.setProperties({
deleted_at: null,
deleted_by: null,
"details.can_delete": true,
"details.can_recover": false
});
return ajax(`/t/${this.id}/recover`, {
data: { context: window.location.pathname },
type: "PUT"
});
},
// Update our attributes from a JSON result
updateFromJson(json) {
const keys = Object.keys(json);
if (!json.view_hidden) {
this.details.updateFromJson(json.details);
keys.removeObjects(["details", "post_stream"]);
}
keys.forEach(key => this.set(key, json[key]));
},
reload() {
return ajax(`/t/${this.id}`, { type: "GET" }).then(topic_json =>
this.updateFromJson(topic_json)
);
},
isPinnedUncategorized: Ember.computed.and(
"pinned",
"category.isUncategorizedCategory"
),
clearPin() {
// Clear the pin optimistically from the object
this.setProperties({ pinned: false, unpinned: true });
ajax(`/t/${this.id}/clear-pin`, {
type: "PUT"
}).then(null, () => {
// On error, put the pin back
this.setProperties({ pinned: true, unpinned: false });
});
},
togglePinnedForUser() {
if (this.pinned) {
this.clearPin();
} else {
this.rePin();
}
},
rePin() {
// Clear the pin optimistically from the object
this.setProperties({ pinned: true, unpinned: false });
ajax(`/t/${this.id}/re-pin`, {
type: "PUT"
}).then(null, () => {
// On error, put the pin back
this.setProperties({ pinned: true, unpinned: false });
});
},
@computed("excerpt")
escapedExcerpt(excerpt) {
return emojiUnescape(excerpt);
},
hasExcerpt: Ember.computed.notEmpty("excerpt"),
@computed("excerpt")
excerptTruncated(excerpt) {
return excerpt && excerpt.substr(excerpt.length - 8, 8) === "&hellip;";
},
readLastPost: propertyEqual("last_read_post_number", "highest_post_number"),
canClearPin: Ember.computed.and("pinned", "readLastPost"),
archiveMessage() {
this.set("archiving", true);
const promise = ajax(`/t/${this.id}/archive-message`, {
type: "PUT"
});
promise
.then(msg => {
this.set("message_archived", true);
if (msg && msg.group_name) {
this.set("inboxGroupName", msg.group_name);
}
})
.finally(() => this.set("archiving", false));
return promise;
},
moveToInbox() {
this.set("archiving", true);
const promise = ajax(`/t/${this.id}/move-to-inbox`, { type: "PUT" });
promise
.then(msg => {
this.set("message_archived", false);
if (msg && msg.group_name) {
this.set("inboxGroupName", msg.group_name);
}
})
.finally(() => this.set("archiving", false));
return promise;
},
publish() {
return ajax(`/t/${this.id}/publish`, {
type: "PUT",
data: this.getProperties("destination_category_id")
})
.then(() => this.set("destination_category_id", null))
.catch(popupAjaxError);
},
updateDestinationCategory(categoryId) {
this.set("destination_category_id", categoryId);
return ajax(`/t/${this.id}/shared-draft`, {
method: "PUT",
data: { category_id: categoryId }
});
},
convertTopic(type, opts) {
let args = { type: "PUT" };
if (opts && opts.categoryId) {
args.data = { category_id: opts.categoryId };
}
return ajax(`/t/${this.id}/convert-topic/${type}`, args);
},
resetBumpDate() {
return ajax(`/t/${this.id}/reset-bump-date`, { type: "PUT" }).catch(
popupAjaxError
);
}
});
Topic.reopenClass({
NotificationLevel: {
WATCHING: 3,
TRACKING: 2,
REGULAR: 1,
MUTED: 0
},
createActionSummary(result) {
if (result.actions_summary) {
const lookup = Ember.Object.create();
result.actions_summary = result.actions_summary.map(a => {
a.post = result;
a.actionType = Discourse.Site.current().postActionTypeById(a.id);
const actionSummary = ActionSummary.create(a);
lookup.set(a.actionType.get("name_key"), actionSummary);
return actionSummary;
});
result.set("actionByName", lookup);
}
},
update(topic, props) {
props = JSON.parse(JSON.stringify(props)) || {};
// We support `category_id` and `categoryId` for compatibility
if (typeof props.categoryId !== "undefined") {
props.category_id = props.categoryId;
delete props.categoryId;
}
// Make sure we never change the category for private messages
if (topic.get("isPrivateMessage")) {
delete props.category_id;
}
if (props.tags && props.tags.length === 0) {
props.tags = [""];
}
return ajax(topic.get("url"), { type: "PUT", data: props }).then(result => {
// The title can be cleaned up server side
props.title = result.basic_topic.title;
props.fancy_title = result.basic_topic.fancy_title;
topic.setProperties(props);
});
},
create() {
const result = this._super.apply(this, arguments);
this.createActionSummary(result);
return result;
},
// Load a topic, but accepts a set of filters
find(topicId, opts) {
let url = Discourse.getURL("/t/") + topicId;
if (opts.nearPost) {
url += `/${opts.nearPost}`;
}
const 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) {
data.username_filters.push(username);
});
}
// Add the summary of filter if we have it
if (opts.summary === true) {
data.summary = true;
}
// Check the preload store. If not, load it via JSON
return ajax(`${url}.json`, { data });
},
changeOwners(topicId, opts) {
const promise = ajax(`/t/${topicId}/change-owner`, {
type: "POST",
data: opts
}).then(result => {
if (result.success) return result;
promise.reject(new Error("error changing ownership of posts"));
});
return promise;
},
changeTimestamp(topicId, timestamp) {
const promise = ajax(`/t/${topicId}/change-timestamp`, {
type: "PUT",
data: { timestamp }
}).then(result => {
if (result.success) return result;
promise.reject(new Error("error updating timestamp of topic"));
});
return promise;
},
bulkOperation(topics, operation) {
return ajax("/topics/bulk", {
type: "PUT",
data: {
topic_ids: topics.map(t => t.get("id")),
operation
}
});
},
bulkOperationByFilter(filter, operation, categoryId, options) {
let data = { filter, operation };
if (options && options.includeSubcategories) {
data.include_subcategories = true;
}
if (categoryId) data.category_id = categoryId;
return ajax("/topics/bulk", {
type: "PUT",
data
});
},
resetNew() {
return ajax("/topics/reset-new", { type: "PUT" });
},
idForSlug(slug) {
return ajax(`/t/id_for/${slug}`);
}
});
function moveResult(result) {
if (result.success) {
// We should be hesitant to flush the map but moving ids is one rare case
flushMap();
return result;
}
throw new Error("error moving posts topic");
}
export function movePosts(topicId, data) {
return ajax(`/t/${topicId}/move-posts`, { type: "POST", data }).then(
moveResult
);
}
export function mergeTopic(topicId, data) {
return ajax(`/t/${topicId}/merge-topic`, { type: "POST", data }).then(
moveResult
);
}
export default Topic;