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
Martin Brennan 41b43a2a25
FEATURE: Add "delete on owner reply" bookmark functionality (#10231)
This adds an option to "delete on owner reply" to bookmarks. If you select this option in the modal, then reply to the topic the bookmark is in, the bookmark will be deleted on reply.

This PR also changes the checkboxes for these additional bookmark options to an Integer column in the DB with a combobox to select the option you want.

The use cases are:

* Sometimes I will bookmark the topics to read it later. In this case we definitely don’t need to keep the bookmark after I replied to it.
* Sometimes I will read the topic in mobile and I will prefer to reply in PC later. Or I may have to do some research before reply. So I will bookmark it for reply later.
2020-07-21 10:00:39 +10:00

1448 lines
39 KiB
JavaScript

import I18n from "I18n";
import { isPresent, isEmpty } from "@ember/utils";
import { or, and, not, alias } from "@ember/object/computed";
import EmberObject from "@ember/object";
import { next, schedule } from "@ember/runloop";
import Controller, { inject as controller } from "@ember/controller";
import { bufferedProperty } from "discourse/mixins/buffered-content";
import Composer from "discourse/models/composer";
import DiscourseURL from "discourse/lib/url";
import Post from "discourse/models/post";
import { buildQuote } from "discourse/lib/quote";
import QuoteState from "discourse/lib/quote-state";
import Topic from "discourse/models/topic";
import discourseDebounce from "discourse/lib/debounce";
import isElementInViewport from "discourse/lib/is-element-in-viewport";
import { ajax } from "discourse/lib/ajax";
import discourseComputed, { observes } from "discourse-common/utils/decorators";
import { extractLinkMeta } from "discourse/lib/render-topic-featured-link";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { spinnerHTML } from "discourse/helpers/loading-spinner";
import { userPath } from "discourse/lib/url";
import showModal from "discourse/lib/show-modal";
import TopicTimer from "discourse/models/topic-timer";
import { Promise } from "rsvp";
import { escapeExpression } from "discourse/lib/utilities";
let customPostMessageCallbacks = {};
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(),
multiSelect: false,
selectedPostIds: null,
editingTopic: false,
queryParams: ["filter", "username_filters"],
loadedAllPosts: or(
"model.postStream.loadedAllPosts",
"model.postStream.loadingLastPost"
),
enteredAt: null,
enteredIndex: null,
retrying: false,
userTriggeredProgress: null,
_progressIndex: null,
hasScrolled: null,
username_filters: null,
filter: null,
quoteState: null,
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.category_id")
showSharedDraftControls(loaded, categoryId) {
let draftCat = this.site.shared_drafts_category_id;
return loaded && draftCat && categoryId && draftCat === categoryId;
},
@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);
},
init() {
this._super(...arguments);
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");
},
_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() {
let posts = this.model.get("postStream").posts;
posts
.filter(p => p.bookmarked && p.bookmark_auto_delete_preference === 2) // 2 is 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: _.merge(
{ 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") + 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;
}
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();
}
},
toggleSummary() {
return this.get("model.postStream")
.toggleSummary()
.then(() => {
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 = Discourse.__container__.lookup("screen-track:main");
const currentUser = this.currentUser;
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 post.toggleBookmark();
} else {
return this.model.toggleBookmark().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
});
},
jumpBottom() {
DiscourseURL.routeTo(this.get("model.lastPostUrl"), {
skipIfOnScreen: false
});
},
jumpEnd() {
this.appEvents.trigger(
"topic:jump-to-post",
this.get("model.highest_post_number")
);
DiscourseURL.routeTo(this.get("model.lastPostUrl"), {
jumpEnd: 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");
},
addNotice(post) {
return new Promise(function(resolve, reject) {
const modal = showModal("add-post-notice");
modal.setProperties({ post, resolve, reject });
});
},
removeNotice(post) {
return post.updatePostField("notice", null).then(() =>
post.setProperties({
notice_type: null,
notice_args: null
})
);
},
toggleParticipant(user) {
this.get("model.postStream")
.toggleParticipant(user.get("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);
});
},
recoverTopic() {
this.model.recover();
},
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,
categoryId: 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.updateStatus(
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")));
} else {
// need to load it
postStream.findPostsByIds([postId]).then(arr => {
DiscourseURL.routeTo(topic.urlForPostNumber(arr[0].get("post_number")));
});
}
},
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;
},
recoverTopic() {
this.model.recover();
},
deleteTopic() {
this.model.destroy(this.currentUser);
},
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 "recovered": {
postStream
.triggerRecoveredPost(data.id)
.then(() => refresh({ id: data.id }));
break;
}
case "created": {
postStream.triggerNewPostInStream(data.id).then(() => refresh());
if (this.get("currentUser.id") !== data.user_id) {
Discourse.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 (dicobot 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: discourseDebounce(function(postNumber) {
const $post = $(`.topic-post article#post_${postNumber}`);
if ($post.length === 0 || isElementInViewport($post)) return;
$("html, body").animate({ scrollTop: $post.offset().top }, 1000);
}, 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 = _.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);
}
});