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/app/controllers/topic.js
Jarek Radosz 5af0636d83
DEV: Fix a leaky test (#13559)
The error was:

```
↪ Unit | Model | topic::recover [✔]
↪ Unit | Utility | emoji::emojiUnescape [✔]
↪ Unit | Utility | pretty-text::quoting a quote [✔]
↪ Unit | Utility | click-track::routes to internal urlsUnhandled request in test environment: /forum/t/1234/recover (PUT)
Error: Unhandled request in test environment: /forum/t/1234/recover (PUT)
    at Pretender.server.unhandledRequest (discourse/tests/setup-tests:173:15)
    at Pretender.handleRequest (pretender:400:14)
    at FakeRequest.send (pretender:169:21)
    at Object.send (jquery:10100:10)
    at Function.ajax (jquery:9683:15)
    at performAjax (discourse/app/lib/ajax:174:19)
    at eval (discourse/app/lib/ajax:183:11)
    at invokeCallback (ember:63104:17)
    at publish (ember:63087:9)
    at eval (ember:57463:16)
 [✘]
```

* DEV: Don't duplicate a function
2021-06-29 10:40:29 +10:00

1703 lines
46 KiB
JavaScript

import Controller, { inject as controller } from "@ember/controller";
import DiscourseURL, { userPath } from "discourse/lib/url";
import { alias, and, not, or } from "@ember/object/computed";
import discourseComputed, { observes } from "discourse-common/utils/decorators";
import { isEmpty, isPresent } from "@ember/utils";
import { later, next, schedule } from "@ember/runloop";
import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark";
import Composer from "discourse/models/composer";
import EmberObject, { action } from "@ember/object";
import I18n from "I18n";
import Post from "discourse/models/post";
import { Promise } from "rsvp";
import QuoteState from "discourse/lib/quote-state";
import Topic from "discourse/models/topic";
import TopicTimer from "discourse/models/topic-timer";
import { ajax } from "discourse/lib/ajax";
import bootbox from "bootbox";
import { bufferedProperty } from "discourse/mixins/buffered-content";
import { buildQuote } from "discourse/lib/quote";
import { deepMerge } from "discourse-common/lib/object";
import discourseDebounce from "discourse-common/lib/debounce";
import { escapeExpression } from "discourse/lib/utilities";
import { extractLinkMeta } from "discourse/lib/render-topic-featured-link";
import isElementInViewport from "discourse/lib/is-element-in-viewport";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { inject as service } from "@ember/service";
import showModal from "discourse/lib/show-modal";
import { spinnerHTML } from "discourse/helpers/loading-spinner";
let customPostMessageCallbacks = {};
const RETRIES_ON_RATE_LIMIT = 4;
export function resetCustomPostMessageCallbacks() {
customPostMessageCallbacks = {};
}
export function registerCustomPostMessageCallback(type, callback) {
if (customPostMessageCallbacks[type]) {
throw new Error(`Error ${type} is an already registered post message!`);
}
customPostMessageCallbacks[type] = callback;
}
export default Controller.extend(bufferedProperty("model"), {
composer: controller(),
application: controller(),
documentTitle: service(),
screenTrack: service(),
multiSelect: false,
selectedPostIds: null,
editingTopic: false,
queryParams: ["filter", "username_filters", "replies_to_post_number"],
loadedAllPosts: or(
"model.postStream.loadedAllPosts",
"model.postStream.loadingLastPost"
),
enteredAt: null,
enteredIndex: null,
retrying: false,
userTriggeredProgress: null,
_progressIndex: null,
hasScrolled: null,
username_filters: null,
replies_to_post_number: null,
filter: null,
quoteState: null,
currentPostId: null,
init() {
this._super(...arguments);
this._retryInProgress = false;
this._retryRateLimited = false;
this._newPostsInStream = [];
this.appEvents.on("post:show-revision", this, "_showRevision");
this.appEvents.on("post:created", this, () => {
this._removeDeleteOnOwnerReplyBookmarks();
this.appEvents.trigger("post-stream:refresh", { force: true });
});
this.setProperties({
selectedPostIds: [],
quoteState: new QuoteState(),
});
},
willDestroy() {
this._super(...arguments);
this.appEvents.off("post:show-revision", this, "_showRevision");
},
canRemoveTopicFeaturedLink: and(
"canEditTopicFeaturedLink",
"buffered.featured_link"
),
updateQueryParams() {
this.setProperties(this.get("model.postStream.streamFilters"));
},
@observes("model.title", "category")
_titleChanged() {
const title = this.get("model.title");
if (!isEmpty(title)) {
// force update lazily loaded titles
this.send("refreshTitle");
}
},
@discourseComputed("model.postStream.loaded", "model.is_shared_draft")
showSharedDraftControls(loaded, isSharedDraft) {
return loaded && isSharedDraft;
},
@discourseComputed("site.mobileView", "model.posts_count")
showSelectedPostsAtBottom(mobileView, postsCount) {
return mobileView && postsCount > 3;
},
@discourseComputed(
"model.postStream.posts",
"model.postStream.postsWithPlaceholders"
)
postsToRender(posts, postsWithPlaceholders) {
return this.capabilities.isAndroid ? posts : postsWithPlaceholders;
},
@discourseComputed("model.postStream.loadingFilter")
androidLoading(loading) {
return this.capabilities.isAndroid && loading;
},
@discourseComputed("model")
pmPath(topic) {
return this.currentUser && this.currentUser.pmPath(topic);
},
_showRevision(postNumber, revision) {
const post = this.model.get("postStream").postForPostNumber(postNumber);
if (!post) {
return;
}
schedule("afterRender", () => this.send("showHistory", post, revision));
},
showCategoryChooser: not("model.isPrivateMessage"),
gotoInbox(name) {
let url = userPath(this.get("currentUser.username_lower") + "/messages");
if (name) {
url = url + "/group/" + name;
}
DiscourseURL.routeTo(url);
},
@discourseComputed
selectedQuery() {
return (post) => this.postSelected(post);
},
@discourseComputed("model.isPrivateMessage", "model.category.id")
canEditTopicFeaturedLink(isPrivateMessage, categoryId) {
if (!this.siteSettings.topic_featured_link_enabled || isPrivateMessage) {
return false;
}
const categoryIds = this.site.get(
"topic_featured_link_allowed_category_ids"
);
return (
categoryIds === undefined ||
!categoryIds.length ||
categoryIds.includes(categoryId)
);
},
@discourseComputed("model")
featuredLinkDomain(topic) {
return extractLinkMeta(topic).domain;
},
@discourseComputed("model.isPrivateMessage")
canEditTags(isPrivateMessage) {
return (
this.site.get("can_tag_topics") &&
(!isPrivateMessage || this.site.get("can_tag_pms"))
);
},
@discourseComputed("model.category")
minimumRequiredTags(category) {
return category && category.minimum_required_tags > 0
? category.minimum_required_tags
: null;
},
_removeDeleteOnOwnerReplyBookmarks() {
const posts = this.get("model.postStream.posts");
if (posts) {
posts
.filter(
(p) =>
p.bookmarked &&
p.bookmark_auto_delete_preference ===
AUTO_DELETE_PREFERENCES.ON_OWNER_REPLY
)
.forEach((p) => {
p.clearBookmark();
});
}
},
_forceRefreshPostStream() {
this.appEvents.trigger("post-stream:refresh", { force: true });
},
_updateSelectedPostIds(postIds) {
const smallActionsPostIds = this._smallActionPostIds();
this.selectedPostIds.pushObjects(
postIds.filter((postId) => !smallActionsPostIds.has(postId))
);
this.set("selectedPostIds", [...new Set(this.selectedPostIds)]);
this._forceRefreshPostStream();
},
_smallActionPostIds() {
const smallActionsPostIds = new Set();
const posts = this.get("model.postStream.posts");
if (posts && this.site) {
const smallAction = this.site.get("post_types.small_action");
const whisper = this.site.get("post_types.whisper");
posts.forEach((post) => {
if (
post.post_type === smallAction ||
(!post.cooked && post.post_type === whisper)
) {
smallActionsPostIds.add(post.id);
}
});
}
return smallActionsPostIds;
},
_loadPostIds(post) {
if (this.loadingPostIds) {
return;
}
const postStream = this.get("model.postStream");
const url = `/t/${this.get("model.id")}/post_ids.json`;
this.set("loadingPostIds", true);
return ajax(url, {
data: deepMerge(
{ post_number: post.get("post_number") },
postStream.get("streamFilters")
),
})
.then((result) => {
result.post_ids.pushObject(post.get("id"));
this._updateSelectedPostIds(result.post_ids);
})
.finally(() => {
this.set("loadingPostIds", false);
});
},
actions: {
topicCategoryChanged(categoryId) {
this.set("buffered.category_id", categoryId);
},
topicTagsChanged(value) {
this.set("buffered.tags", value);
},
deletePending(pending) {
return ajax(`/review/${pending.id}`, { type: "DELETE" })
.then(() => {
this.get("model.pending_posts").removeObject(pending);
})
.catch(popupAjaxError);
},
showPostFlags(post) {
return this.send("showFlags", post);
},
openFeatureTopic() {
this.send("showFeatureTopic");
},
selectText() {
const { postId, buffer, opts } = this.quoteState;
const loadedPost = this.get("model.postStream").findLoadedPost(postId);
const promise = loadedPost
? Promise.resolve(loadedPost)
: this.get("model.postStream").loadPost(postId);
return promise.then((post) => {
const composer = this.composer;
const viewOpen = composer.get("model.viewOpen");
// If we can't create a post, delegate to reply as new topic
if (!viewOpen && !this.get("model.details.can_create_post")) {
this.send("replyAsNewTopic", post);
return;
}
const composerOpts = {
action: Composer.REPLY,
draftSequence: post.get("topic.draft_sequence"),
draftKey: post.get("topic.draft_key"),
};
if (post.get("post_number") === 1) {
composerOpts.topic = post.get("topic");
} else {
composerOpts.post = post;
}
// If the composer is associated with a different post, we don't change it.
const composerPost = composer.get("model.post");
if (composerPost && composerPost.get("id") !== this.get("post.id")) {
composerOpts.post = composerPost;
}
const quotedText = buildQuote(post, buffer, opts);
composerOpts.quote = quotedText;
if (composer.get("model.viewOpen")) {
this.appEvents.trigger("composer:insert-block", quotedText);
} else if (composer.get("model.viewDraft")) {
const model = composer.get("model");
model.set("reply", model.get("reply") + "\n" + quotedText);
composer.send("openIfDraft");
} else {
composer.open(composerOpts);
}
});
},
fillGapBefore(args) {
return this.get("model.postStream").fillGapBefore(args.post, args.gap);
},
fillGapAfter(args) {
return this.get("model.postStream").fillGapAfter(args.post, args.gap);
},
currentPostChanged(event) {
const { post } = event;
if (!post) {
return;
}
this.set("currentPostId", post.id);
const postNumber = post.get("post_number");
const topic = this.model;
topic.set("currentPost", postNumber);
if (postNumber > (topic.get("last_read_post_number") || 0)) {
topic.set("last_read_post_id", post.get("id"));
topic.set("last_read_post_number", postNumber);
}
this.send("postChangedRoute", postNumber);
this._progressIndex = topic.get("postStream").progressIndexOfPost(post);
this.appEvents.trigger("topic:current-post-changed", { post });
},
currentPostScrolled(event) {
const total = this.get("model.postStream.filteredPostsCount");
const percent =
parseFloat(this._progressIndex + event.percent - 1) / total;
this.appEvents.trigger("topic:current-post-scrolled", {
postIndex: this._progressIndex,
percent: Math.max(Math.min(percent, 1.0), 0.0),
});
},
// Called when the topmost visible post on the page changes.
topVisibleChanged(event) {
const { post, refresh } = event;
if (!post) {
return;
}
const postStream = this.get("model.postStream");
const firstLoadedPost = postStream.get("posts.firstObject");
if (post.get && post.get("post_number") === 1) {
return;
}
if (firstLoadedPost && firstLoadedPost === post) {
postStream.prependMore().then(() => refresh());
}
},
// Called the the bottommost visible post on the page changes.
bottomVisibleChanged(event) {
const { post, refresh } = event;
const postStream = this.get("model.postStream");
const lastLoadedPost = postStream.get("posts.lastObject");
if (
lastLoadedPost &&
lastLoadedPost === post &&
postStream.get("canAppendMore")
) {
postStream.appendMore().then(() => refresh());
// show loading stuff
refresh();
}
},
showSummary() {
return this.get("model.postStream")
.showSummary()
.then(() => {
this.updateQueryParams();
});
},
cancelFilter(nearestPost = null) {
const postStream = this.get("model.postStream");
if (!nearestPost) {
const loadedPost = postStream.findLoadedPost(this.currentPostId);
if (loadedPost) {
nearestPost = loadedPost.post_number;
} else {
postStream.findPostsByIds([this.currentPostId]).then((arr) => {
nearestPost = arr[0].post_number;
});
}
}
postStream.cancelFilter();
postStream
.refresh({
nearPost: nearestPost,
forceLoad: true,
})
.then(() => {
DiscourseURL.routeTo(this.model.urlForPostNumber(nearestPost));
this.updateQueryParams();
});
},
removeAllowedUser(user) {
return this.get("model.details")
.removeAllowedUser(user)
.then(() => {
if (this.currentUser.id === user.id) {
this.transitionToRoute("userPrivateMessages", user);
}
});
},
removeAllowedGroup(group) {
return this.get("model.details").removeAllowedGroup(group);
},
deleteTopic() {
this.deleteTopic();
},
// Archive a PM (as opposed to archiving a topic)
toggleArchiveMessage() {
const topic = this.model;
if (topic.get("archiving")) {
return;
}
const backToInbox = () => this.gotoInbox(topic.get("inboxGroupName"));
if (topic.get("message_archived")) {
topic.moveToInbox().then(backToInbox);
} else {
topic.archiveMessage().then(backToInbox);
}
},
deferTopic() {
const { screenTrack, currentUser } = this;
const topic = this.model;
screenTrack.reset();
screenTrack.stop();
const goToPath = topic.get("isPrivateMessage")
? currentUser.pmPath(topic)
: "/";
ajax("/t/" + topic.get("id") + "/timings.json?last=1", { type: "DELETE" })
.then(() => {
const highestSeenByTopic = this.session.get("highestSeenByTopic");
highestSeenByTopic[topic.get("id")] = null;
DiscourseURL.routeTo(goToPath);
})
.catch(popupAjaxError);
},
editFirstPost() {
this.model
.firstPost()
.then((firstPost) => this.send("editPost", firstPost));
},
// Post related methods
replyToPost(post) {
const composerController = this.composer;
const topic = post ? post.get("topic") : this.model;
const quoteState = this.quoteState;
const postStream = this.get("model.postStream");
this.appEvents.trigger("page:compose-reply", topic);
if (!postStream || !topic || !topic.get("details.can_create_post")) {
return;
}
const quotedPost = postStream.findLoadedPost(quoteState.postId);
const quotedText = buildQuote(
quotedPost,
quoteState.buffer,
quoteState.opts
);
quoteState.clear();
if (
composerController.get("model.topic.id") === topic.get("id") &&
composerController.get("model.action") === Composer.REPLY
) {
composerController.set("model.post", post);
composerController.set("model.composeState", Composer.OPEN);
this.appEvents.trigger("composer:insert-block", quotedText.trim());
} else {
const opts = {
action: Composer.REPLY,
draftKey: topic.get("draft_key"),
draftSequence: topic.get("draft_sequence"),
};
if (quotedText) {
opts.quote = quotedText;
}
if (post && post.get("post_number") !== 1) {
opts.post = post;
} else {
opts.topic = topic;
}
composerController.open(opts);
}
return false;
},
recoverPost(post) {
post.get("post_number") === 1 ? this.recoverTopic() : post.recover();
},
deletePost(post) {
if (post.get("post_number") === 1) {
return this.deleteTopic();
} else if (!post.can_delete) {
return false;
}
const user = this.currentUser;
const refresh = () => this.appEvents.trigger("post-stream:refresh");
const hasReplies = post.get("reply_count") > 0;
const loadedPosts = this.get("model.postStream.posts");
if (user.get("staff") && hasReplies) {
ajax(`/posts/${post.id}/reply-ids.json`).then((replies) => {
if (replies.length === 0) {
return post
.destroy(user)
.then(refresh)
.catch((error) => {
popupAjaxError(error);
post.undoDeleteState();
});
}
const buttons = [];
buttons.push({
label: I18n.t("cancel"),
class: "btn-danger right",
});
buttons.push({
label: I18n.t("post.controls.delete_replies.just_the_post"),
callback() {
post
.destroy(user)
.then(refresh)
.catch((error) => {
popupAjaxError(error);
post.undoDeleteState();
});
},
});
if (replies.some((r) => r.level > 1)) {
buttons.push({
label: I18n.t("post.controls.delete_replies.all_replies", {
count: replies.length,
}),
callback() {
loadedPosts.forEach(
(p) =>
(p === post || replies.some((r) => r.id === p.id)) &&
p.setDeletedState(user)
);
Post.deleteMany([post.id, ...replies.map((r) => r.id)])
.then(refresh)
.catch(popupAjaxError);
},
});
}
const directReplyIds = replies
.filter((r) => r.level === 1)
.map((r) => r.id);
buttons.push({
label: I18n.t("post.controls.delete_replies.direct_replies", {
count: directReplyIds.length,
}),
class: "btn-primary",
callback() {
loadedPosts.forEach(
(p) =>
(p === post || directReplyIds.includes(p.id)) &&
p.setDeletedState(user)
);
Post.deleteMany([post.id, ...directReplyIds])
.then(refresh)
.catch(popupAjaxError);
},
});
bootbox.dialog(
I18n.t("post.controls.delete_replies.confirm"),
buttons
);
});
} else {
return post
.destroy(user)
.then(refresh)
.catch((error) => {
popupAjaxError(error);
post.undoDeleteState();
});
}
},
editPost(post) {
if (!this.currentUser) {
return bootbox.alert(I18n.t("post.controls.edit_anonymous"));
} else if (!post.can_edit) {
return false;
}
const composer = this.composer;
let topic = this.model;
const composerModel = composer.get("model");
let editingFirst =
composerModel &&
(post.get("firstPost") || composerModel.get("editingFirstPost"));
let editingSharedDraft = false;
let draftsCategoryId = this.get("site.shared_drafts_category_id");
if (draftsCategoryId && draftsCategoryId === topic.get("category.id")) {
editingSharedDraft = post.get("firstPost");
}
const opts = {
post,
action: editingSharedDraft ? Composer.EDIT_SHARED_DRAFT : Composer.EDIT,
draftKey: post.get("topic.draft_key"),
draftSequence: post.get("topic.draft_sequence"),
};
if (editingSharedDraft) {
opts.destinationCategoryId = topic.get("destination_category_id");
}
// Cancel and reopen the composer for the first post
if (editingFirst) {
composer.cancelComposer().then(() => composer.open(opts));
} else {
composer.open(opts);
}
},
toggleBookmark(post) {
if (!this.currentUser) {
return bootbox.alert(I18n.t("bookmarks.not_bookmarked"));
} else if (post) {
return this._togglePostBookmark(post);
} else {
return this._toggleTopicBookmark(this.model).then((changedIds) => {
if (!changedIds) {
return;
}
changedIds.forEach((id) =>
this.appEvents.trigger("post-stream:refresh", { id })
);
});
}
},
jumpToIndex(index) {
this._jumpToIndex(index);
},
jumpToDate(date) {
this._jumpToDate(date);
},
jumpToPostPrompt() {
const topic = this.model;
const modal = showModal("jump-to-post", {
modalClass: "jump-to-post-modal",
});
modal.setProperties({
topic,
postNumber: null,
jumpToIndex: (index) => this.send("jumpToIndex", index),
jumpToDate: (date) => this.send("jumpToDate", date),
});
},
jumpToPost(postNumber) {
this._jumpToPostNumber(postNumber);
},
jumpTop() {
DiscourseURL.routeTo(this.get("model.firstPostUrl"), {
skipIfOnScreen: false,
keepFilter: true,
});
},
jumpBottom() {
// When a topic only has one lengthy post
const jumpEnd = this.model.highest_post_number === 1 ? true : false;
DiscourseURL.routeTo(this.get("model.lastPostUrl"), {
skipIfOnScreen: false,
jumpEnd,
keepFilter: true,
});
},
jumpEnd() {
this.appEvents.trigger(
"topic:jump-to-post",
this.get("model.highest_post_number")
);
DiscourseURL.routeTo(this.get("model.lastPostUrl"), {
jumpEnd: true,
keepFilter: true,
});
},
jumpUnread() {
this._jumpToPostId(this.get("model.last_read_post_id"));
},
jumpToPostId(postId) {
this._jumpToPostId(postId);
},
toggleMultiSelect() {
this.toggleProperty("multiSelect");
this._forceRefreshPostStream();
},
selectAll() {
const smallActionsPostIds = this._smallActionPostIds();
this.set("selectedPostIds", [
...this.get("model.postStream.stream").filter(
(postId) => !smallActionsPostIds.has(postId)
),
]);
this._forceRefreshPostStream();
},
deselectAll() {
this.set("selectedPostIds", []);
this._forceRefreshPostStream();
},
togglePostSelection(post) {
const selected = this.selectedPostIds;
selected.includes(post.id)
? selected.removeObject(post.id)
: selected.addObject(post.id);
},
selectReplies(post) {
ajax(`/posts/${post.id}/reply-ids.json`).then((replies) => {
const replyIds = replies.map((r) => r.id);
this.selectedPostIds.pushObjects([post.id, ...replyIds]);
this._forceRefreshPostStream();
});
},
selectBelow(post) {
if (this.get("model.postStream.isMegaTopic")) {
this._loadPostIds(post);
} else {
const stream = [...this.get("model.postStream.stream")];
const below = stream.slice(stream.indexOf(post.id));
this._updateSelectedPostIds(below);
}
},
deleteSelected() {
const user = this.currentUser;
bootbox.confirm(
I18n.t("post.delete.confirm", {
count: this.selectedPostsCount,
}),
(result) => {
if (result) {
// If all posts are selected, it's the same thing as deleting the topic
if (this.selectedAllPosts) {
return this.deleteTopic();
}
Post.deleteMany(this.selectedPostIds);
this.get("model.postStream.posts").forEach(
(p) => this.postSelected(p) && p.setDeletedState(user)
);
this.send("toggleMultiSelect");
}
}
);
},
mergePosts() {
bootbox.confirm(
I18n.t("post.merge.confirm", { count: this.selectedPostsCount }),
(result) => {
if (result) {
Post.mergePosts(this.selectedPostIds);
this.send("toggleMultiSelect");
}
}
);
},
changePostOwner(post) {
this.set("selectedPostIds", [post.id]);
this.send("changeOwner");
},
lockPost(post) {
return post.updatePostField("locked", true);
},
unlockPost(post) {
return post.updatePostField("locked", false);
},
grantBadge(post) {
this.set("selectedPostIds", [post.id]);
this.send("showGrantBadgeModal");
},
changeNotice(post) {
return new Promise(function (resolve, reject) {
const modal = showModal("change-post-notice", { model: post });
modal.setProperties({
resolve,
reject,
notice: post.notice ? post.notice.raw : "",
});
});
},
filterParticipant(user) {
this.get("model.postStream")
.filterParticipant(user.username)
.then(() => this.updateQueryParams);
},
editTopic() {
if (this.get("model.details.can_edit")) {
this.set("editingTopic", true);
}
return false;
},
cancelEditingTopic() {
this.set("editingTopic", false);
this.rollbackBuffer();
},
finishedEditingTopic() {
if (!this.editingTopic) {
return;
}
// save the modifications
const props = this.get("buffered.buffer");
Topic.update(this.model, props)
.then(() => {
// We roll back on success here because `update` saves the properties to the topic
this.rollbackBuffer();
this.set("editingTopic", false);
})
.catch(popupAjaxError);
},
expandHidden(post) {
return post.expandHidden();
},
toggleVisibility() {
this.model.toggleStatus("visible");
},
toggleClosed() {
const topic = this.model;
this.model.toggleStatus("closed").then((result) => {
topic.set("topic_status_update", result.topic_status_update);
});
},
makeBanner() {
this.model.makeBanner();
},
removeBanner() {
this.model.removeBanner();
},
togglePinned() {
const value = this.get("model.pinned_at") ? false : true,
topic = this.model,
until = this.get("model.pinnedInCategoryUntil");
// optimistic update
topic.setProperties({
pinned_at: value ? moment() : null,
pinned_globally: false,
pinned_until: value ? until : null,
});
return topic.saveStatus("pinned", value, until);
},
pinGlobally() {
const topic = this.model,
until = this.get("model.pinnedGloballyUntil");
// optimistic update
topic.setProperties({
pinned_at: moment(),
pinned_globally: true,
pinned_until: until,
});
return topic.saveStatus("pinned_globally", true, until);
},
toggleArchived() {
this.model.toggleStatus("archived");
},
clearPin() {
this.model.clearPin();
},
togglePinnedForUser() {
if (this.get("model.pinned_at")) {
const topic = this.model;
if (topic.get("pinned")) {
topic.clearPin();
} else {
topic.rePin();
}
}
},
replyAsNewTopic(post) {
const composerController = this.composer;
const { quoteState } = this;
const quotedText = buildQuote(post, quoteState.buffer, quoteState.opts);
quoteState.clear();
let options;
if (this.get("model.isPrivateMessage")) {
let users = this.get("model.details.allowed_users");
let groups = this.get("model.details.allowed_groups");
let recipients = [];
users.forEach((user) => recipients.push(user.username));
groups.forEach((group) => recipients.push(group.name));
recipients = recipients.join();
options = {
action: Composer.PRIVATE_MESSAGE,
archetypeId: "private_message",
draftKey: post.topic.draft_key,
recipients,
};
} else {
options = {
action: Composer.CREATE_TOPIC,
draftKey: post.topic.draft_key,
topicCategoryId: this.get("model.category.id"),
prioritizedCategoryId: this.get("model.category.id"),
};
}
composerController.open(options).then(() => {
const title = escapeExpression(this.model.title);
const postUrl = `${location.protocol}//${location.host}${post.url}`;
const postLink = `[${title}](${postUrl})`;
const text = `${I18n.t("post.continue_discussion", {
postLink,
})}\n\n${quotedText}`;
composerController.model.prependText(text, { new_line: true });
});
},
retryLoading() {
this.set("retrying", true);
const rollback = () => this.set("retrying", false);
this.get("model.postStream").refresh().then(rollback, rollback);
},
toggleWiki(post) {
return post.updatePostField("wiki", !post.get("wiki"));
},
togglePostType(post) {
const regular = this.site.get("post_types.regular");
const moderator = this.site.get("post_types.moderator_action");
return post.updatePostField(
"post_type",
post.get("post_type") === moderator ? regular : moderator
);
},
rebakePost(post) {
return post.rebake();
},
unhidePost(post) {
return post.unhide();
},
convertToPublicTopic() {
showModal("convert-to-public-topic", {
model: this.model,
modalClass: "convert-to-public-topic",
});
},
convertToPrivateMessage() {
this.model
.convertTopic("private")
.then(() => window.location.reload())
.catch(popupAjaxError);
},
removeFeaturedLink() {
this.set("buffered.featured_link", null);
},
resetBumpDate() {
this.model.resetBumpDate();
},
removeTopicTimer(statusType, topicTimer) {
TopicTimer.update(this.get("model.id"), null, null, statusType, null)
.then(() => this.set(`model.${topicTimer}`, EmberObject.create({})))
.catch((error) => popupAjaxError(error));
},
},
_jumpToIndex(index) {
const postStream = this.get("model.postStream");
if (postStream.get("isMegaTopic")) {
this._jumpToPostNumber(index);
} else {
const stream = postStream.get("stream");
const streamIndex = Math.max(1, Math.min(stream.length, index));
this._jumpToPostId(stream[streamIndex - 1]);
}
},
_jumpToDate(date) {
const postStream = this.get("model.postStream");
postStream
.loadNearestPostToDate(date)
.then((post) => {
DiscourseURL.routeTo(
this.model.urlForPostNumber(post.get("post_number"))
);
})
.catch(() => {
this._jumpToIndex(postStream.get("topic.highest_post_number"));
});
},
_jumpToPostNumber(postNumber) {
const postStream = this.get("model.postStream");
const post = postStream.get("posts").findBy("post_number", postNumber);
if (post) {
DiscourseURL.routeTo(
this.model.urlForPostNumber(post.get("post_number"))
);
} else {
postStream.loadPostByPostNumber(postNumber).then((p) => {
DiscourseURL.routeTo(this.model.urlForPostNumber(p.get("post_number")));
});
}
},
_jumpToPostId(postId) {
if (!postId) {
// eslint-disable-next-line no-console
console.warn(
"jump-post code broken - requested an index outside the stream array"
);
return;
}
this.appEvents.trigger("topic:jump-to-post", postId);
const topic = this.model;
const postStream = topic.get("postStream");
const post = postStream.findLoadedPost(postId);
if (post) {
DiscourseURL.routeTo(topic.urlForPostNumber(post.get("post_number")), {
keepFilter: true,
});
} else {
// need to load it
postStream.findPostsByIds([postId]).then((arr) => {
DiscourseURL.routeTo(
topic.urlForPostNumber(arr[0].get("post_number")),
{
keepFilter: true,
}
);
});
}
},
_togglePostBookmark(post) {
return new Promise((resolve) => {
let modalController = showModal("bookmark", {
model: {
postId: post.id,
id: post.bookmark_id,
reminderAt: post.bookmark_reminder_at,
autoDeletePreference: post.bookmark_auto_delete_preference,
name: post.bookmark_name,
},
title: post.bookmark_id
? "post.bookmarks.edit"
: "post.bookmarks.create",
modalClass: "bookmark-with-reminder",
});
modalController.setProperties({
onCloseWithoutSaving: () => {
resolve({ closedWithoutSaving: true });
post.appEvents.trigger("post-stream:refresh", { id: post.id });
},
afterSave: (savedData) => {
this._addOrUpdateBookmarkedPost(post.id, savedData.reminderAt);
post.createBookmark(savedData);
resolve({ closedWithoutSaving: false });
},
afterDelete: (topicBookmarked) => {
this.model.set(
"bookmarked_posts",
this.model.bookmarked_posts.filter((x) => x.post_id !== post.id)
);
post.deleteBookmark(topicBookmarked);
},
});
});
},
_addOrUpdateBookmarkedPost(postId, reminderAt) {
if (!this.model.bookmarked_posts) {
this.model.set("bookmarked_posts", []);
}
let bookmarkedPost = this.model.bookmarked_posts.findBy("post_id", postId);
if (!bookmarkedPost) {
bookmarkedPost = { post_id: postId };
this.model.bookmarked_posts.pushObject(bookmarkedPost);
}
bookmarkedPost.reminder_at = reminderAt;
},
_toggleTopicBookmark() {
if (this.model.bookmarking) {
return Promise.resolve();
}
this.model.set("bookmarking", true);
const bookmarkedPostsCount = this.model.bookmarked_posts
? this.model.bookmarked_posts.length
: 0;
const bookmarkPost = async (post) => {
const opts = await this._togglePostBookmark(post);
this.model.set("bookmarking", false);
if (opts.closedWithoutSaving) {
return;
}
this.model.afterPostBookmarked(post);
return [post.id];
};
const toggleBookmarkOnServer = async () => {
if (bookmarkedPostsCount === 0) {
const firstPost = await this.model.firstPost();
return bookmarkPost(firstPost);
} else if (bookmarkedPostsCount === 1) {
const postId = this.model.bookmarked_posts[0].post_id;
const post = await this.model.postById(postId);
return bookmarkPost(post);
} else {
return this.model
.deleteBookmarks()
.then(() => this.model.clearBookmarks())
.catch(popupAjaxError)
.finally(() => this.model.set("bookmarking", false));
}
};
return new Promise((resolve) => {
if (bookmarkedPostsCount > 1) {
bootbox.confirm(
I18n.t("bookmarks.confirm_clear"),
I18n.t("no_value"),
I18n.t("yes_value"),
(confirmed) => {
if (confirmed) {
toggleBookmarkOnServer().then(resolve);
} else {
this.model.set("bookmarking", false);
resolve();
}
}
);
} else {
toggleBookmarkOnServer().then(resolve);
}
});
},
togglePinnedState() {
this.send("togglePinnedForUser");
},
print() {
if (this.siteSettings.max_prints_per_hour_per_user > 0) {
window.open(
this.get("model.printUrl"),
"",
"menubar=no,toolbar=no,resizable=yes,scrollbars=yes,width=600,height=315"
);
}
},
hasError: or("model.errorHtml", "model.errorMessage"),
noErrorYet: not("hasError"),
categories: alias("site.categoriesList"),
selectedPostsCount: alias("selectedPostIds.length"),
@discourseComputed(
"selectedPostIds",
"model.postStream.posts",
"selectedPostIds.[]",
"model.postStream.posts.[]"
)
selectedPosts(selectedPostIds, loadedPosts) {
return selectedPostIds
.map((id) => loadedPosts.find((p) => p.id === id))
.filter((post) => post !== undefined);
},
@discourseComputed("selectedPostsCount", "selectedPosts", "selectedPosts.[]")
selectedPostsUsername(selectedPostsCount, selectedPosts) {
if (selectedPosts.length < 1 || selectedPostsCount > selectedPosts.length) {
return undefined;
}
const username = selectedPosts[0].username;
return selectedPosts.every((p) => p.username === username)
? username
: undefined;
},
@discourseComputed(
"selectedPostsCount",
"model.postStream.isMegaTopic",
"model.postStream.stream.length",
"model.posts_count"
)
selectedAllPosts(
selectedPostsCount,
isMegaTopic,
postsCount,
topicPostsCount
) {
if (isMegaTopic) {
return selectedPostsCount >= topicPostsCount;
} else {
return selectedPostsCount >= postsCount;
}
},
@discourseComputed("selectedAllPosts", "model.postStream.isMegaTopic")
canSelectAll(selectedAllPosts, isMegaTopic) {
return isMegaTopic ? false : !selectedAllPosts;
},
canDeselectAll: alias("selectedAllPosts"),
@discourseComputed(
"currentUser.staff",
"selectedPostsCount",
"selectedAllPosts",
"selectedPosts",
"selectedPosts.[]"
)
canDeleteSelected(
isStaff,
selectedPostsCount,
selectedAllPosts,
selectedPosts
) {
return (
selectedPostsCount > 0 &&
((selectedAllPosts && isStaff) ||
selectedPosts.every((p) => p.can_delete))
);
},
@discourseComputed("model.details.can_move_posts", "selectedPostsCount")
canMergeTopic(canMovePosts, selectedPostsCount) {
return canMovePosts && selectedPostsCount > 0;
},
@discourseComputed(
"currentUser.admin",
"selectedPostsCount",
"selectedPostsUsername"
)
canChangeOwner(isAdmin, selectedPostsCount, selectedPostsUsername) {
return (
isAdmin && selectedPostsCount > 0 && selectedPostsUsername !== undefined
);
},
@discourseComputed(
"selectedPostsCount",
"selectedPostsUsername",
"selectedPosts",
"selectedPosts.[]"
)
canMergePosts(selectedPostsCount, selectedPostsUsername, selectedPosts) {
return (
selectedPostsCount > 1 &&
selectedPostsUsername !== undefined &&
selectedPosts.every((p) => p.can_delete)
);
},
@observes("multiSelect")
_multiSelectChanged() {
this.set("selectedPostIds", []);
},
postSelected(post) {
return this.selectedAllPost || this.selectedPostIds.includes(post.id);
},
@discourseComputed
loadingHTML() {
return spinnerHTML;
},
@action
recoverTopic() {
this.model.recover();
},
deleteTopic() {
if (
this.model.views > this.siteSettings.min_topic_views_for_delete_confirm
) {
this.deleteTopicModal();
} else {
this.model.destroy(this.currentUser);
}
},
deleteTopicModal() {
showModal("delete-topic-confirm", {
model: this.model,
title: "topic.actions.delete",
});
},
retryOnRateLimit(times, promise, topicId) {
const currentTopicId = this.get("model.id");
topicId = topicId || currentTopicId;
if (topicId !== currentTopicId) {
// we navigated to another topic, so skip
return;
}
if (this._retryRateLimited || times <= 0) {
return;
}
if (this._retryInProgress) {
later(() => {
this.retryOnRateLimit(times, promise, topicId);
}, 100);
return;
}
this._retryInProgress = true;
promise()
.catch((e) => {
const xhr = e.jqXHR;
if (
xhr &&
xhr.status === 429 &&
xhr.responseJSON &&
xhr.responseJSON.extras &&
xhr.responseJSON.extras.wait_seconds
) {
let waitSeconds = xhr.responseJSON.extras.wait_seconds;
if (waitSeconds < 5) {
waitSeconds = 5;
}
this._retryRateLimited = true;
later(() => {
this._retryRateLimited = false;
this.retryOnRateLimit(times - 1, promise, topicId);
}, waitSeconds * 1000);
}
})
.finally(() => {
this._retryInProgress = false;
});
},
subscribe() {
this.unsubscribe();
const refresh = (args) =>
this.appEvents.trigger("post-stream:refresh", args);
this.messageBus.subscribe(
`/topic/${this.get("model.id")}`,
(data) => {
const topic = this.model;
if (isPresent(data.notification_level_change)) {
topic.set(
"details.notification_level",
data.notification_level_change
);
topic.set(
"details.notifications_reason_id",
data.notifications_reason_id
);
return;
}
const postStream = this.get("model.postStream");
if (data.reload_topic) {
topic.reload().then(() => {
this.send("postChangedRoute", topic.get("post_number") || 1);
this.appEvents.trigger("header:update-topic", topic);
if (data.refresh_stream) {
postStream.refresh();
}
});
return;
}
switch (data.type) {
case "acted":
postStream
.triggerChangedPost(data.id, data.updated_at, {
preserveCooked: true,
})
.then(() => refresh({ id: data.id, refreshLikes: true }));
break;
case "read": {
postStream
.triggerReadPost(data.id, data.readers_count)
.then(() => refresh({ id: data.id, refreshLikes: true }));
break;
}
case "revised":
case "rebaked": {
postStream
.triggerChangedPost(data.id, data.updated_at)
.then(() => refresh({ id: data.id }));
break;
}
case "deleted": {
postStream
.triggerDeletedPost(data.id)
.then(() => refresh({ id: data.id }));
break;
}
case "destroyed": {
postStream
.triggerDestroyedPost(data.id)
.then(() => refresh({ id: data.id }));
break;
}
case "recovered": {
postStream
.triggerRecoveredPost(data.id)
.then(() => refresh({ id: data.id }));
break;
}
case "created": {
this._newPostsInStream.push(data.id);
this.retryOnRateLimit(RETRIES_ON_RATE_LIMIT, () => {
const postIds = this._newPostsInStream;
this._newPostsInStream = [];
return postStream
.triggerNewPostsInStream(postIds, { background: true })
.then(() => refresh())
.catch((e) => {
this._newPostsInStream = postIds.concat(
this._newPostsInStream
);
throw e;
});
});
if (this.get("currentUser.id") !== data.user_id) {
this.documentTitle.incrementBackgroundContextCount();
}
break;
}
case "move_to_inbox": {
topic.set("message_archived", false);
break;
}
case "archived": {
topic.set("message_archived", true);
break;
}
default: {
let callback = customPostMessageCallbacks[data.type];
if (callback) {
callback(this, data);
} else {
// eslint-disable-next-line no-console
console.warn("unknown topic bus message type", data);
}
}
}
// scroll to bottom is very specific to new posts from discobot
// hence the -2 check (discobot id). We can shift all this code
// to discobot plugin longer term
if (
topic.get("isPrivateMessage") &&
this.currentUser &&
this.currentUser.get("id") !== data.user_id &&
data.user_id === -2 &&
data.type === "created"
) {
const postNumber = data.post_number;
const notInPostStream =
topic.get("highest_post_number") <= postNumber;
const postNumberDifference = postNumber - topic.get("currentPost");
if (
notInPostStream &&
postNumberDifference > 0 &&
postNumberDifference < 7
) {
this._scrollToPost(data.post_number);
}
}
},
this.get("model.message_bus_last_id")
);
},
_scrollToPost(postNumber) {
discourseDebounce(
this,
function () {
const $post = $(`.topic-post article#post_${postNumber}`);
if ($post.length === 0 || isElementInViewport($post[0])) {
return;
}
$("html, body").animate({ scrollTop: $post.offset().top }, 1000);
},
postNumber,
500
);
},
unsubscribe() {
// never unsubscribe when navigating from topic to topic
if (!this.get("model.id")) {
return;
}
this.messageBus.unsubscribe("/topic/*");
},
reply() {
this.replyToPost();
},
readPosts(topicId, postNumbers) {
const topic = this.model;
const postStream = topic.get("postStream");
if (topic.get("id") === topicId) {
postStream.get("posts").forEach((post) => {
if (!post.read && postNumbers.includes(post.post_number)) {
post.set("read", true);
this.appEvents.trigger("post-stream:refresh", { id: post.get("id") });
}
});
if (
this.siteSettings.automatically_unpin_topics &&
this.currentUser &&
this.currentUser.automatically_unpin_topics
) {
// automatically unpin topics when the user reaches the bottom
const max = Math.max(...postNumbers);
if (topic.get("pinned") && max >= topic.get("highest_post_number")) {
next(() => topic.clearPin());
}
}
}
},
@observes("model.postStream.loaded", "model.postStream.loadedAllPosts")
_showFooter() {
const showFooter =
this.get("model.postStream.loaded") &&
this.get("model.postStream.loadedAllPosts");
this.set("application.showFooter", showFooter);
},
});