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/composer.js
Bianca Nenciu f351200683
FIX: Show tag chooser if can_tag_pms (#18107)
The old logic did not make sense and hid the selector from regular users
even if they could tag PMs or showed selector for admins even if they
could not tag PMs.
2022-08-29 15:52:19 +03:00

1506 lines
40 KiB
JavaScript

import Composer, { SAVE_ICONS, SAVE_LABELS } from "discourse/models/composer";
import Controller, { inject as controller } from "@ember/controller";
import EmberObject, { action, computed } from "@ember/object";
import { alias, and, or, reads } from "@ember/object/computed";
import {
authorizesOneOrMoreExtensions,
uploadIcon,
} from "discourse/lib/uploads";
import { cancel, scheduleOnce } from "@ember/runloop";
import {
cannotPostAgain,
durationTextFromSeconds,
} from "discourse/helpers/slow-mode";
import discourseComputed, {
observes,
on,
} from "discourse-common/utils/decorators";
import DiscourseURL from "discourse/lib/url";
import Draft from "discourse/models/draft";
import I18n from "I18n";
import { iconHTML } from "discourse-common/lib/icon-library";
import { Promise } from "rsvp";
import bootbox from "bootbox";
import { buildQuote } from "discourse/lib/quote";
import deprecated from "discourse-common/lib/deprecated";
import discourseDebounce from "discourse-common/lib/debounce";
import { emojiUnescape } from "discourse/lib/text";
import { escapeExpression } from "discourse/lib/utilities";
import { getOwner } from "discourse-common/lib/get-owner";
import getURL from "discourse-common/lib/get-url";
import { isEmpty } from "@ember/utils";
import { isTesting } from "discourse-common/config/environment";
import { inject as service } from "@ember/service";
import { shortDate } from "discourse/lib/formatter";
import showModal from "discourse/lib/show-modal";
async function loadDraft(store, opts = {}) {
let { draft, draftKey, draftSequence } = opts;
try {
if (draft && typeof draft === "string") {
draft = JSON.parse(draft);
}
} catch (error) {
draft = null;
Draft.clear(draftKey, draftSequence);
}
if (!draft?.title && !draft?.reply) {
return;
}
let attrs = {
draftKey,
draftSequence,
draft: true,
composerState: Composer.DRAFT,
topic: opts.topic,
};
Composer.serializedFieldsForDraft().forEach((f) => {
attrs[f] = draft[f] || opts[f];
});
const composer = store.createRecord("composer");
await composer.open(attrs);
return composer;
}
const _popupMenuOptionsCallbacks = [];
let _checkDraftPopup = !isTesting();
export function toggleCheckDraftPopup(enabled) {
_checkDraftPopup = enabled;
}
export function clearPopupMenuOptionsCallback() {
_popupMenuOptionsCallbacks.length = 0;
}
export function addPopupMenuOptionsCallback(callback) {
_popupMenuOptionsCallbacks.push(callback);
}
export default Controller.extend({
topicController: controller("topic"),
router: service(),
checkedMessages: false,
messageCount: null,
showEditReason: false,
editReason: null,
scopedCategoryId: null,
prioritizedCategoryId: null,
lastValidatedAt: null,
isUploading: false,
isProcessingUpload: false,
topic: null,
linkLookup: null,
showPreview: true,
composerHeight: null,
forcePreview: and("site.mobileView", "showPreview"),
whisperOrUnlistTopic: or("isWhispering", "model.unlistTopic"),
categories: alias("site.categoriesList"),
@on("init")
_setupPreview() {
const val = this.site.mobileView
? false
: this.keyValueStore.get("composer.showPreview") || "true";
this.set("showPreview", val === "true");
},
@computed(
"model.loading",
"isUploading",
"isProcessingUpload",
"_disableSubmit"
)
get disableSubmit() {
return (
this.model?.loading ||
this.isUploading ||
this.isProcessingUpload ||
this._disableSubmit
);
},
set disableSubmit(value) {
return this.set("_disableSubmit", value);
},
@discourseComputed("showPreview")
toggleText(showPreview) {
return showPreview
? I18n.t("composer.hide_preview")
: I18n.t("composer.show_preview");
},
@observes("showPreview")
showPreviewChanged() {
if (!this.site.mobileView) {
this.keyValueStore.set({
key: "composer.showPreview",
value: this.showPreview,
});
}
},
@discourseComputed(
"model.replyingToTopic",
"model.creatingPrivateMessage",
"model.targetRecipients",
"model.composeState"
)
focusTarget(replyingToTopic, creatingPM, usernames, composeState) {
// Focus on usernames if it's blank or if it's just you
usernames = usernames || "";
if (
(creatingPM && usernames.length === 0) ||
usernames === this.currentUser.username
) {
return "usernames";
}
if (replyingToTopic) {
return "reply";
}
if (composeState === Composer.FULLSCREEN) {
return "editor";
}
return "title";
},
showToolbar: computed({
get() {
const keyValueStore = getOwner(this).lookup("service:key-value-store");
const storedVal = keyValueStore.get("toolbar-enabled");
if (this._toolbarEnabled === undefined && storedVal === undefined) {
// iPhone 6 is 375, anything narrower and toolbar should
// be default disabled.
// That said we should remember the state
this._toolbarEnabled =
window.innerWidth > 370 && !this.capabilities.isAndroid;
}
return this._toolbarEnabled || storedVal === "true";
},
set(key, val) {
const keyValueStore = getOwner(this).lookup("service:key-value-store");
this._toolbarEnabled = val;
keyValueStore.set({
key: "toolbar-enabled",
value: val ? "true" : "false",
});
return val;
},
}),
topicModel: alias("topicController.model"),
@discourseComputed("model.canEditTitle", "model.creatingPrivateMessage")
canEditTags(canEditTitle, creatingPrivateMessage) {
if (creatingPrivateMessage && this.site.mobileView) {
return false;
}
const isPrivateMessage =
creatingPrivateMessage || this.get("model.topic.isPrivateMessage");
return (
canEditTitle &&
this.site.can_tag_topics &&
(!isPrivateMessage || this.site.can_tag_pms)
);
},
@discourseComputed("model.editingPost", "model.topic.details.can_edit")
disableCategoryChooser(editingPost, canEditTopic) {
return editingPost && !canEditTopic;
},
@discourseComputed("model.editingPost", "model.topic.canEditTags")
disableTagsChooser(editingPost, canEditTags) {
return editingPost && !canEditTags;
},
isStaffUser: reads("currentUser.staff"),
whisperer: reads("currentUser.whisperer"),
canUnlistTopic: and("model.creatingTopic", "isStaffUser"),
@discourseComputed("canWhisper", "replyingToWhisper")
showWhisperToggle(canWhisper, replyingToWhisper) {
return canWhisper && !replyingToWhisper;
},
@discourseComputed("model.post")
replyingToWhisper(repliedToPost) {
return (
repliedToPost && repliedToPost.post_type === this.site.post_types.whisper
);
},
isWhispering: or("replyingToWhisper", "model.whisper"),
@discourseComputed("model.action", "isWhispering", "model.privateMessage")
saveIcon(modelAction, isWhispering, privateMessage) {
if (isWhispering) {
return "far-eye-slash";
}
if (privateMessage && modelAction === Composer.REPLY) {
return "envelope";
}
return SAVE_ICONS[modelAction];
},
// Note we update when some other attributes like tag/category change to allow
// text customizations to use those.
@discourseComputed(
"model.action",
"isWhispering",
"model.editConflict",
"model.privateMessage",
"model.tags",
"model.category"
)
saveLabel(modelAction, isWhispering, editConflict, privateMessage) {
let result = this.model.customizationFor("saveLabel");
if (result) {
return result;
}
if (editConflict) {
return "composer.overwrite_edit";
} else if (isWhispering) {
return "composer.create_whisper";
} else if (privateMessage && modelAction === Composer.REPLY) {
return "composer.create_pm";
}
return SAVE_LABELS[modelAction];
},
@discourseComputed("whisperer", "model.action")
canWhisper(whisperer, modelAction) {
return (
this.siteSettings.enable_whispers &&
Composer.REPLY === modelAction &&
whisperer
);
},
_setupPopupMenuOption(callback) {
let option = callback(this);
if (typeof option === "undefined") {
return null;
}
if (typeof option.condition === "undefined") {
option.condition = true;
} else if (typeof option.condition === "boolean") {
// uses existing value
} else {
option.condition = this.get(option.condition);
}
return option;
},
@discourseComputed("model.requiredCategoryMissing", "model.replyLength")
disableTextarea(requiredCategoryMissing, replyLength) {
return requiredCategoryMissing && replyLength === 0;
},
@discourseComputed("model.composeState", "model.creatingTopic", "model.post")
popupMenuOptions(composeState) {
if (composeState === "open" || composeState === "fullscreen") {
const options = [];
options.push(
this._setupPopupMenuOption(() => {
return {
action: "toggleInvisible",
icon: "far-eye-slash",
label: "composer.toggle_unlisted",
condition: "canUnlistTopic",
};
})
);
if (this.site.mobileView) {
options.push(
this._setupPopupMenuOption(() => {
return {
action: "applyUnorderedList",
icon: "list-ul",
label: "composer.ulist_title",
};
})
);
options.push(
this._setupPopupMenuOption(() => {
return {
action: "applyOrderedList",
icon: "list-ol",
label: "composer.olist_title",
};
})
);
}
options.push(
this._setupPopupMenuOption(() => {
return {
action: "toggleWhisper",
icon: "far-eye-slash",
label: "composer.toggle_whisper",
condition: "showWhisperToggle",
};
})
);
return options.concat(
_popupMenuOptionsCallbacks
.map((callback) => this._setupPopupMenuOption(callback))
.filter((o) => o)
);
}
},
@discourseComputed("model.creatingPrivateMessage", "model.targetRecipients")
showWarning(creatingPrivateMessage, usernames) {
if (!this.get("currentUser.staff")) {
return false;
}
const hasTargetGroups = this.get("model.hasTargetGroups");
// We need exactly one user to issue a warning
if (
isEmpty(usernames) ||
usernames.split(",").length !== 1 ||
hasTargetGroups
) {
return false;
}
return creatingPrivateMessage;
},
@discourseComputed("model.topic.title")
draftTitle(topicTitle) {
return emojiUnescape(escapeExpression(topicTitle));
},
@discourseComputed
allowUpload() {
return authorizesOneOrMoreExtensions(
this.currentUser.staff,
this.siteSettings
);
},
@discourseComputed()
uploadIcon() {
return uploadIcon(this.currentUser.staff, this.siteSettings);
},
// Use this to open the composer when you are not sure whether it is
// already open and whether it already has a draft being worked on. Supports
// options to append text once the composer is open if required.
//
// opts:
//
// - topic: if this is present, the composer will be opened with the reply
// action and the current topic key and draft sequence
// - fallbackToNewTopic: if true, and there is no draft and no topic,
// the composer will be opened with the create_topic action and a new
// topic draft key
// - insertText: the text to append to the composer once it is opened
// - openOpts: this object will be passed to this.open if fallbackToNewTopic is
// true or topic is provided
@action
async focusComposer(opts = {}) {
await this._openComposerForFocus(opts);
this._focusAndInsertText(opts.insertText);
},
async _openComposerForFocus(opts) {
if (this.get("model.viewOpen")) {
return;
}
const opened = this.openIfDraft();
if (opened) {
return;
}
if (opts.topic) {
return await this.open({
action: Composer.REPLY,
draftKey: opts.topic.get("draft_key"),
draftSequence: opts.topic.get("draft_sequence"),
topic: opts.topic,
...(opts.openOpts || {}),
});
}
if (opts.fallbackToNewTopic) {
return await this.open({
action: Composer.CREATE_TOPIC,
draftKey: Composer.NEW_TOPIC_KEY,
...(opts.openOpts || {}),
});
}
},
_focusAndInsertText(insertText) {
scheduleOnce("afterRender", () => {
document.querySelector("textarea.d-editor-input")?.focus();
if (insertText) {
this.model.appendText(insertText, null, { new_line: true });
}
});
},
@action
openIfDraft(event) {
if (!this.get("model.viewDraft")) {
return false;
}
// when called from shortcut, ensure we don't propagate the key to
// the composer input title
if (event) {
event.preventDefault();
event.stopPropagation();
}
this.set("model.composeState", Composer.OPEN);
document.documentElement.style.setProperty(
"--composer-height",
this.get("model.composerHeight")
);
return true;
},
@action
removeFullScreenExitPrompt() {
this.set("model.showFullScreenExitPrompt", false);
},
actions: {
togglePreview() {
this.toggleProperty("showPreview");
},
closeComposer() {
this.close();
},
async openComposer(options, post, topic) {
await this.open(options);
let url = post?.url || topic?.url;
const topicTitle = topic?.title;
if (!url || !topicTitle) {
return;
}
url = `${location.protocol}//${location.host}${url}`;
const link = `[${escapeExpression(topicTitle)}](${url})`;
const continueDiscussion = I18n.t("post.continue_discussion", {
postLink: link,
});
const reply = this.get("model.reply");
if (reply?.includes(continueDiscussion)) {
return;
}
this.model.prependText(continueDiscussion, {
new_line: true,
});
},
cancelUpload() {
this.set("model.uploadCancelled", true);
},
onPopupMenuAction(menuAction) {
this.send(menuAction);
},
storeToolbarState(toolbarEvent) {
this.set("toolbarEvent", toolbarEvent);
},
typed() {
this.checkReplyLength();
this.model.typing();
},
cancelled() {
this.send("hitEsc");
},
addLinkLookup(linkLookup) {
this.set("linkLookup", linkLookup);
},
afterRefresh($preview) {
const topic = this.get("model.topic");
const linkLookup = this.linkLookup;
if (!topic || !linkLookup) {
return;
}
// Don't check if there's only one post
if (topic.posts_count === 1) {
return;
}
const post = this.get("model.post");
const $links = $("a[href]", $preview);
$links.each((idx, l) => {
const href = l.href;
if (href && href.length) {
// skip links added by watched words
if (l.dataset.word !== undefined) {
return true;
}
// skip links in quotes and oneboxes
for (let element = l; element; element = element.parentElement) {
if (
element.tagName === "DIV" &&
element.classList.contains("d-editor-preview")
) {
break;
}
if (
element.tagName === "ASIDE" &&
element.classList.contains("quote")
) {
return true;
}
if (
element.tagName === "ASIDE" &&
element.classList.contains("onebox") &&
href !== element.dataset["onebox-src"]
) {
return true;
}
}
const [linkWarn, linkInfo] = linkLookup.check(post, href);
if (linkWarn && !this.get("isWhispering")) {
const body = I18n.t("composer.duplicate_link", {
domain: linkInfo.domain,
username: linkInfo.username,
post_url: topic.urlForPostNumber(linkInfo.post_number),
ago: shortDate(linkInfo.posted_at),
});
this.appEvents.trigger("composer-messages:create", {
extraClass: "custom-body",
templateName: "custom-body",
body,
});
return false;
}
}
return true;
});
},
toggleWhisper() {
this.toggleProperty("model.whisper");
},
toggleInvisible() {
this.toggleProperty("model.unlistTopic");
},
toggleToolbar() {
this.toggleProperty("showToolbar");
},
// Toggle the reply view
async toggle() {
this.closeAutocomplete();
const composer = this.model;
if (isEmpty(composer?.reply) && isEmpty(composer?.title)) {
this.close();
} else if (composer?.viewOpenOrFullscreen) {
this.shrink();
} else {
await this.cancelComposer();
}
},
fullscreenComposer() {
this.toggleFullscreen();
return false;
},
// Import a quote from the post
async importQuote(toolbarEvent) {
const postStream = this.get("topic.postStream");
let postId = this.get("model.post.id");
// If there is no current post, use the first post id from the stream
if (!postId && postStream) {
postId = postStream.get("stream.firstObject");
}
// If we're editing a post, fetch the reply when importing a quote
if (this.get("model.editingPost")) {
const replyToPostNumber = this.get("model.post.reply_to_post_number");
if (replyToPostNumber) {
const replyPost = postStream.posts.findBy(
"post_number",
replyToPostNumber
);
if (replyPost) {
postId = replyPost.id;
}
}
}
if (!postId) {
return;
}
this.set("model.loading", true);
const post = await this.store.find("post", postId);
const quote = buildQuote(post, post.raw, { full: true });
toolbarEvent.addText(quote);
this.set("model.loading", false);
},
async cancel() {
await this.cancelComposer();
},
save(ignore, event) {
this.save(false, {
jump:
!(event?.shiftKey && this.get("model.replyingToTopic")) &&
!this.skipJumpOnSave,
});
},
displayEditReason() {
this.set("showEditReason", true);
},
hitEsc() {
if (
document.querySelectorAll(".emoji-picker-modal.fadeIn").length === 1
) {
this.appEvents.trigger("emoji-picker:close");
return;
}
if ((this.messageCount || 0) > 0) {
this.appEvents.trigger("composer-messages:close");
return;
}
const composer = this.model;
if (composer?.viewOpen) {
this.shrink();
}
if (composer?.viewFullscreen) {
this.toggleFullscreen();
this.focusComposer();
}
},
groupsMentioned(groups) {
if (
!this.get("model.creatingPrivateMessage") &&
!this.get("model.topic.isPrivateMessage")
) {
groups.forEach((group) => {
let body;
const groupLink = getURL(`/g/${group.name}/members`);
const maxMentions = parseInt(group.max_mentions, 10);
const userCount = parseInt(group.user_count, 10);
if (maxMentions < userCount) {
body = I18n.t("composer.group_mentioned_limit", {
group: `@${group.name}`,
count: maxMentions,
group_link: groupLink,
});
} else if (group.user_count > 0) {
body = I18n.t("composer.group_mentioned", {
group: `@${group.name}`,
count: userCount,
group_link: groupLink,
});
}
if (body) {
this.appEvents.trigger("composer-messages:create", {
extraClass: "custom-body",
templateName: "custom-body",
body,
});
}
});
}
},
cannotSeeMention(mentions) {
mentions.forEach((mention) => {
this.appEvents.trigger("composer-messages:create", {
extraClass: "custom-body",
templateName: "custom-body",
body: I18n.t(`composer.cannot_see_mention.${mention.reason}`, {
username: mention.name,
}),
});
});
},
hereMention(count) {
this.appEvents.trigger("composer-messages:create", {
extraClass: "custom-body",
templateName: "custom-body",
body: I18n.t("composer.here_mention", {
here: this.siteSettings.here_mention,
count,
}),
});
},
applyUnorderedList() {
this.toolbarEvent.applyList("* ", "list_item");
},
applyOrderedList() {
this.toolbarEvent.applyList(
(i) => (!i ? "1. " : `${parseInt(i, 10) + 1}. `),
"list_item"
);
},
},
save(force, options = {}) {
if (this.disableSubmit) {
return;
}
// Clear the warning state if we're not showing the checkbox anymore
if (!this.showWarning) {
this.set("model.isWarning", false);
}
if (this.site.mobileView && this.showPreview) {
this.set("showPreview", false);
}
const composer = this.model;
if (composer?.cantSubmitPost) {
if (composer?.viewFullscreen) {
this.toggleFullscreen();
}
this.set("lastValidatedAt", Date.now());
return;
}
const topic = composer.topic;
const slowModePost =
topic && topic.slow_mode_seconds && topic.user_last_posted_at;
const notEditing = this.get("model.action") !== "edit";
// Editing a topic in slow mode is directly handled by the backend.
if (slowModePost && notEditing) {
if (
cannotPostAgain(
this.currentUser,
topic.slow_mode_seconds,
topic.user_last_posted_at
)
) {
const canPostAt = new moment(topic.user_last_posted_at).add(
topic.slow_mode_seconds,
"seconds"
);
const timeLeft = moment().diff(canPostAt, "seconds");
const message = I18n.t("composer.slow_mode.error", {
timeLeft: durationTextFromSeconds(timeLeft),
});
bootbox.alert(message);
return;
} else {
// Edge case where the user tries to post again immediately.
topic.set("user_last_posted_at", new Date().toISOString());
}
}
composer.set("disableDrafts", true);
// for now handle a very narrow use case
// if we are replying to a topic
// AND are on on a different topic
// AND topic is open (or we are staff)
// --> pop the window up
if (!force && composer.replyingToTopic) {
const currentTopic = this.topicModel;
if (!currentTopic) {
this.save(true);
return;
}
if (
currentTopic.id !== composer.get("topic.id") &&
(this.isStaffUser || !currentTopic.closed)
) {
const message =
"<h1>" + I18n.t("composer.posting_not_on_topic") + "</h1>";
let buttons = [
{
label: I18n.t("composer.cancel"),
class: "btn-flat btn-text btn-reply-where-cancel",
},
];
buttons.push({
label:
I18n.t("composer.reply_here") +
"<br/><div class='topic-title overflow-ellipsis'>" +
currentTopic.get("fancyTitle") +
"</div>",
class: "btn-reply-here",
callback: () => {
composer.setProperties({ topic: currentTopic, post: null });
this.save(true);
},
});
buttons.push({
label:
I18n.t("composer.reply_original") +
"<br/><div class='topic-title overflow-ellipsis'>" +
this.get("model.topic.fancyTitle") +
"</div>",
class: "btn-primary btn-reply-on-original",
callback: () => this.save(true),
});
bootbox.dialog(message, buttons, { classes: "reply-where-modal" });
return;
}
}
let staged = false;
// TODO: This should not happen in model
const imageSizes = {};
document
.querySelectorAll("#reply-control .d-editor-preview img")
.forEach((e) => {
const src = e.src;
if (src && src.length) {
imageSizes[src] = { width: e.naturalWidth, height: e.naturalHeight };
}
});
const promise = composer
.save({ imageSizes, editReason: this.editReason })
.then((result) => {
this.appEvents.trigger("composer:saved");
if (result.responseJson.action === "enqueued") {
this.send("postWasEnqueued", result.responseJson);
if (result.responseJson.pending_post) {
let pendingPosts = this.get("topicController.model.pending_posts");
if (pendingPosts) {
pendingPosts.pushObject(result.responseJson.pending_post);
}
}
return this.destroyDraft().then(() => {
this.close();
this.appEvents.trigger("post-stream:refresh");
return result;
});
}
if (this.get("model.editingPost")) {
this.appEvents.trigger("composer:edited-post");
this.appEvents.trigger("post-stream:refresh", {
id: parseInt(result.responseJson.id, 10),
});
if (result.responseJson.post.post_number === 1) {
this.appEvents.trigger("header:update-topic", composer.topic);
}
} else {
this.appEvents.trigger("post-stream:refresh");
}
if (result.responseJson.action === "create_post") {
this.appEvents.trigger("composer:created-post");
this.appEvents.trigger(
"post:highlight",
result.payload.post_number,
options
);
}
if (this.get("model.draftKey") === Composer.NEW_TOPIC_KEY) {
this.currentUser.set("has_topic_draft", false);
}
if (result.responseJson.route_to) {
// TODO: await this:
this.destroyDraft();
if (result.responseJson.message) {
return bootbox.alert(result.responseJson.message, () => {
DiscourseURL.routeTo(result.responseJson.route_to);
});
}
return DiscourseURL.routeTo(result.responseJson.route_to);
}
this.close();
this.currentUser.set("any_posts", true);
const post = result.target;
if (post && !staged && options.jump !== false) {
DiscourseURL.routeTo(post.url, {
keepFilter: true,
skipIfOnScreen: true,
});
}
})
.catch((error) => {
composer.set("disableDrafts", false);
if (error) {
this.appEvents.one("composer:will-open", () => bootbox.alert(error));
}
});
if (
this.router.currentRouteName.split(".")[0] === "topic" &&
composer.get("topic.id") === this.get("topicModel.id")
) {
staged = composer.get("stagedPost");
}
this.appEvents.trigger("post-stream:posted", staged);
this.messageBus.pause();
promise.finally(() => this.messageBus.resume());
return promise;
},
// Notify the composer messages controller that a reply has been typed. Some
// messages only appear after typing.
checkReplyLength() {
if (!isEmpty("model.reply")) {
this.appEvents.trigger("composer:typed-reply");
}
},
/**
Open the composer view
@method open
@param {Object} opts Options for creating a post
@param {String} opts.action The action we're performing: edit, reply, createTopic, createSharedDraft, privateMessage
@param {String} opts.draftKey
@param {Post} [opts.post] The post we're replying to
@param {Topic} [opts.topic] The topic we're replying to
@param {String} [opts.quote] If we're opening a reply from a quote, the quote we're making
@param {Boolean} [opts.ignoreIfChanged]
@param {Boolean} [opts.disableScopedCategory]
@param {Number} [opts.categoryId] Sets `scopedCategoryId` and `categoryId` on the Composer model
@param {Number} [opts.prioritizedCategoryId]
@param {String} [opts.draftSequence]
@param {Boolean} [opts.skipDraftCheck]
@param {Boolean} [opts.skipJumpOnSave] Option to skip navigating to the post when saved in this composer session
**/
async open(opts = {}) {
if (!opts.draftKey) {
throw new Error("composer opened without a proper draft key");
}
let composerModel = this.model;
if (
opts.ignoreIfChanged &&
composerModel &&
composerModel.composeState !== Composer.CLOSED
) {
return;
}
this.setProperties({
showEditReason: false,
editReason: null,
scopedCategoryId: null,
prioritizedCategoryId: null,
skipAutoSave: true,
});
this.set("skipJumpOnSave", !!opts.skipJumpOnSave);
// Scope the categories drop down to the category we opened the composer with.
if (opts.categoryId && !opts.disableScopedCategory) {
const category = this.site.categories.findBy("id", opts.categoryId);
if (category) {
this.set("scopedCategoryId", opts.categoryId);
}
}
if (opts.prioritizedCategoryId) {
const category = this.site.categories.findBy(
"id",
opts.prioritizedCategoryId
);
if (category) {
this.set("prioritizedCategoryId", opts.prioritizedCategoryId);
}
}
// If we want a different draft than the current composer, close it and clear our model.
if (
composerModel &&
opts.draftKey !== composerModel.draftKey &&
composerModel.composeState === Composer.DRAFT
) {
this.close();
composerModel = null;
}
try {
if (composerModel?.replyDirty) {
// If we're already open, we don't have to do anything
if (
composerModel.composeState === Composer.OPEN &&
composerModel.draftKey === opts.draftKey &&
!opts.action
) {
return;
}
// If it's the same draft, just open it up again.
if (
composerModel.composeState === Composer.DRAFT &&
composerModel.draftKey === opts.draftKey
) {
composerModel.set("composeState", Composer.OPEN);
if (!opts.action) {
return;
}
}
await this.cancelComposer();
await this.open(opts);
return;
}
if (composerModel && composerModel.action !== opts.action) {
composerModel.setProperties({ unlistTopic: false, whisper: false });
}
// we need a draft sequence for the composer to work
if (opts.draftSequence === undefined) {
let data = await Draft.get(opts.draftKey);
if (opts.skipDraftCheck) {
data.draft = undefined;
} else {
data = await this.confirmDraftAbandon(data);
}
opts.draft ||= data.draft;
opts.draftSequence = data.draft_sequence;
await this._setModel(composerModel, opts);
return;
}
// otherwise, do the draft check async
if (!opts.draft && !opts.skipDraftCheck) {
let data = await Draft.get(opts.draftKey);
data = await this.confirmDraftAbandon(data);
if (data.draft) {
opts.draft = data.draft;
opts.draftSequence = data.draft_sequence;
await this.open(opts);
}
}
await this._setModel(composerModel, opts);
} finally {
this.skipAutoSave = false;
}
},
// Given a potential instance and options, set the model for this composer.
async _setModel(optionalComposerModel, opts) {
this.set("linkLookup", null);
let composerModel;
if (opts.draft) {
composerModel = await loadDraft(this.store, opts);
if (!composerModel) {
throw new Error("draft was not found");
}
} else {
const model =
optionalComposerModel || this.store.createRecord("composer");
await model.open(opts);
composerModel = model;
}
this.set("model", composerModel);
composerModel.setProperties({
composeState: Composer.OPEN,
isWarning: false,
hasTargetGroups: opts.hasGroups,
});
if (!this.model.targetRecipients) {
if (opts.usernames) {
deprecated("`usernames` is deprecated, use `recipients` instead.");
this.model.set("targetRecipients", opts.usernames);
} else if (opts.recipients) {
this.model.set("targetRecipients", opts.recipients);
}
}
if (
opts.topicTitle &&
opts.topicTitle.length <= this.siteSettings.max_topic_title_length
) {
this.model.set("title", opts.topicTitle);
}
if (opts.topicCategoryId) {
this.model.set("categoryId", opts.topicCategoryId);
}
if (opts.topicTags && this.site.can_tag_topics) {
let tags = escapeExpression(opts.topicTags)
.split(",")
.slice(0, this.siteSettings.max_tags_per_topic);
tags.forEach(
(tag, index, array) =>
(array[index] = tag.substring(0, this.siteSettings.max_tag_length))
);
this.model.set("tags", tags);
}
if (opts.topicBody) {
this.model.set("reply", opts.topicBody);
}
// The two custom properties below can be overriden by themes/plugins to set different default composer heights.
const defaultComposerHeight =
this.model.action === "reply"
? "var(--reply-composer-height, 300px)"
: "var(--new-topic-composer-height, 400px)";
this.set("model.composerHeight", defaultComposerHeight);
document.documentElement.style.setProperty(
"--composer-height",
defaultComposerHeight
);
},
viewNewReply() {
DiscourseURL.routeTo(this.get("model.createdPost.url"));
this.close();
return false;
},
async destroyDraft(draftSequence = null) {
const key = this.get("model.draftKey");
if (!key) {
return;
}
if (key === Composer.NEW_TOPIC_KEY) {
this.currentUser.set("has_topic_draft", false);
}
if (this._saveDraftPromise) {
await this._saveDraftPromise;
return await this.destroyDraft();
}
const sequence = draftSequence || this.get("model.draftSequence");
await Draft.clear(key, sequence);
this.appEvents.trigger("draft:destroyed", key);
},
confirmDraftAbandon(data) {
if (!data.draft) {
return data;
}
// do not show abandon dialog if old draft is clean
const draft = JSON.parse(data.draft);
if (draft.reply === draft.originalText) {
data.draft = null;
return data;
}
if (!_checkDraftPopup) {
data.draft = null;
return data;
}
return new Promise((resolve) => {
bootbox.dialog(I18n.t("drafts.abandon.confirm"), [
{
label: I18n.t("drafts.abandon.no_value"),
callback: () => resolve(data),
},
{
label: I18n.t("drafts.abandon.yes_value"),
class: "btn-danger",
icon: iconHTML("far-trash-alt"),
callback: () => {
this.destroyDraft(data.draft_sequence).finally(() => {
data.draft = null;
resolve(data);
});
},
},
]);
});
},
cancelComposer() {
this.skipAutoSave = true;
if (this._saveDraftDebounce) {
cancel(this._saveDraftDebounce);
}
return new Promise((resolve) => {
if (this.get("model.hasMetaData") || this.get("model.replyDirty")) {
const modal = showModal("discard-draft", {
model: this.model,
modalClass: "discard-draft-modal",
});
modal.setProperties({
onDestroyDraft: () => {
return this.destroyDraft()
.then(() => {
this.model.clearState();
this.close();
})
.finally(() => {
this.appEvents.trigger("composer:cancelled");
resolve();
});
},
onSaveDraft: () => {
this._saveDraft();
this.model.clearState();
this.close();
this.appEvents.trigger("composer:cancelled");
return resolve();
},
// needed to resume saving drafts if composer stays open
onDismissModal: () => resolve(),
});
} else {
// it is possible there is some sort of crazy draft with no body ... just give up on it
this.destroyDraft()
.then(() => {
this.model.clearState();
this.close();
})
.finally(() => {
this.appEvents.trigger("composer:cancelled");
resolve();
});
}
}).finally(() => {
this.skipAutoSave = false;
});
},
shrink() {
if (
this.get("model.replyDirty") ||
(this.get("model.canEditTitle") && this.get("model.titleDirty"))
) {
this.collapse();
} else {
this.close();
}
},
_saveDraft() {
if (!this.model) {
return;
}
if (this.model.draftSaving) {
this._saveDraftDebounce = discourseDebounce(this, this._saveDraft, 2000);
} else {
this._saveDraftPromise = this.model
.saveDraft(this.currentUser)
.finally(() => {
this._lastDraftSaved = Date.now();
this._saveDraftPromise = null;
});
}
},
@observes("model.reply", "model.title")
_shouldSaveDraft() {
if (
this.model &&
!this.model.loading &&
!this.skipAutoSave &&
!this.model.disableDrafts
) {
if (!this._lastDraftSaved) {
// pretend so we get a save unconditionally in 15 secs
this._lastDraftSaved = Date.now();
}
if (Date.now() - this._lastDraftSaved > 15000) {
this._saveDraft();
} else {
this._saveDraftDebounce = discourseDebounce(
this,
this._saveDraft,
2000
);
}
}
},
@discourseComputed("model.categoryId", "lastValidatedAt")
categoryValidation(categoryId, lastValidatedAt) {
if (!this.siteSettings.allow_uncategorized_topics && !categoryId) {
return EmberObject.create({
failed: true,
reason: I18n.t("composer.error.category_missing"),
lastShownAt: lastValidatedAt,
});
}
},
@discourseComputed("model.category", "model.tags", "lastValidatedAt")
tagValidation(category, tags, lastValidatedAt) {
const tagsArray = tags || [];
if (this.site.can_tag_topics && !this.currentUser.staff && category) {
// category.minimumRequiredTags incorporates both minimum_required_tags, and required_tag_groups
if (category.minimumRequiredTags > tagsArray.length) {
return EmberObject.create({
failed: true,
reason: I18n.t("composer.error.tags_missing", {
count: category.minimumRequiredTags,
}),
lastShownAt: lastValidatedAt,
});
}
}
},
collapse() {
this._saveDraft();
this.set("model.composeState", Composer.DRAFT);
document.documentElement.style.setProperty("--composer-height", "40px");
},
toggleFullscreen() {
this._saveDraft();
const composer = this.model;
if (composer?.viewFullscreen) {
composer?.set("composeState", Composer.OPEN);
} else {
composer?.set("composeState", Composer.FULLSCREEN);
composer?.set("showFullScreenExitPrompt", true);
}
},
@discourseComputed("model.viewFullscreen", "model.showFullScreenExitPrompt")
showFullScreenPrompt(isFullscreen, showExitPrompt) {
return isFullscreen && showExitPrompt && !this.capabilities.touch;
},
close() {
// the 'fullscreen-composer' class is added to remove scrollbars from the
// document while in fullscreen mode. If the composer is closed for any reason
// this class should be removed
const elem = document.querySelector("html");
elem.classList.remove("fullscreen-composer");
elem.classList.remove("composer-open");
document.activeElement?.blur();
document.documentElement.style.removeProperty("--composer-height");
this.setProperties({ model: null, lastValidatedAt: null });
},
closeAutocomplete() {
$(".d-editor-input").autocomplete({ cancel: true });
},
@discourseComputed("model.action")
canEdit(modelAction) {
return modelAction === "edit" && this.currentUser.can_edit;
},
@discourseComputed("model.composeState")
visible(state) {
return state && state !== "closed";
},
clearLastValidatedAt() {
this.set("lastValidatedAt", null);
},
});