mirror of
https://github.com/discourse/discourse.git
synced 2025-05-30 07:11:34 +08:00
DEV: [gjs-codemod] merge js and hbs
This commit is contained in:
@ -1,3 +1,7 @@
|
||||
import ComboBoxSelectBoxHeaderComponent from "select-kit/components/combo-box/combo-box-header";
|
||||
|
||||
export default class ChatChannelChooserHeader extends ComboBoxSelectBoxHeaderComponent {}
|
||||
|
||||
<div class="select-kit-header-wrapper">
|
||||
{{#if this.selectedContent}}
|
||||
<ChannelTitle @channel={{this.selectedContent}} />
|
||||
|
@ -1,3 +0,0 @@
|
||||
import ComboBoxSelectBoxHeaderComponent from "select-kit/components/combo-box/combo-box-header";
|
||||
|
||||
export default class ChatChannelChooserHeader extends ComboBoxSelectBoxHeaderComponent {}
|
@ -1 +1,7 @@
|
||||
import { classNames } from "@ember-decorators/component";
|
||||
import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row";
|
||||
|
||||
@classNames("chat-channel-chooser-row")
|
||||
export default class ChatChannelChooserRow extends SelectKitRowComponent {}
|
||||
|
||||
<ChannelTitle @channel={{this.item}} />
|
@ -1,5 +0,0 @@
|
||||
import { classNames } from "@ember-decorators/component";
|
||||
import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row";
|
||||
|
||||
@classNames("chat-channel-chooser-row")
|
||||
export default class ChatChannelChooserRow extends SelectKitRowComponent {}
|
@ -1,3 +1,144 @@
|
||||
import Component from "@ember/component";
|
||||
import { action } from "@ember/object";
|
||||
import { getOwner } from "@ember/owner";
|
||||
import { service } from "@ember/service";
|
||||
import { classNames } from "@ember-decorators/component";
|
||||
import { bind } from "discourse/lib/decorators";
|
||||
import { cloneJSON } from "discourse/lib/object";
|
||||
import UppyUpload from "discourse/lib/uppy/uppy-upload";
|
||||
import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin";
|
||||
import { clipboardHelpers } from "discourse/lib/utilities";
|
||||
|
||||
@classNames("chat-composer-uploads")
|
||||
export default class ChatComposerUploads extends Component {
|
||||
@service mediaOptimizationWorker;
|
||||
@service chatStateManager;
|
||||
|
||||
uppyUpload = new UppyUpload(getOwner(this), {
|
||||
id: "chat-composer-uploader",
|
||||
type: "chat-composer",
|
||||
useMultipartUploadsIfAvailable: true,
|
||||
|
||||
uppyReady: () => {
|
||||
if (this.siteSettings.composer_media_optimization_image_enabled) {
|
||||
this.uppyUpload.uppyWrapper.useUploadPlugin(UppyMediaOptimization, {
|
||||
optimizeFn: (data, opts) =>
|
||||
this.mediaOptimizationWorker.optimizeImage(data, opts),
|
||||
runParallel: !this.site.isMobileDevice,
|
||||
});
|
||||
}
|
||||
|
||||
this.uppyUpload.uppyWrapper.onPreProcessProgress((file) => {
|
||||
const inProgressUpload = this.inProgressUploads.findBy("id", file.id);
|
||||
if (!inProgressUpload?.processing) {
|
||||
inProgressUpload?.set("processing", true);
|
||||
}
|
||||
});
|
||||
|
||||
this.uppyUpload.uppyWrapper.onPreProcessComplete((file) => {
|
||||
const inProgressUpload = this.inProgressUploads.findBy("id", file.id);
|
||||
inProgressUpload?.set("processing", false);
|
||||
});
|
||||
},
|
||||
|
||||
uploadDone: (upload) => {
|
||||
this.uploads.pushObject(upload);
|
||||
this._triggerUploadsChanged();
|
||||
},
|
||||
|
||||
uploadDropTargetOptions: () => ({
|
||||
target: this.uploadDropZone || document.body,
|
||||
}),
|
||||
|
||||
onProgressUploadsChanged: () => {
|
||||
this._triggerUploadsChanged(this.uploads, {
|
||||
inProgressUploadsCount: this.inProgressUploads?.length,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
existingUploads = null;
|
||||
uploads = null;
|
||||
uploadDropZone = null;
|
||||
|
||||
get inProgressUploads() {
|
||||
return this.uppyUpload.inProgressUploads;
|
||||
}
|
||||
|
||||
didReceiveAttrs() {
|
||||
super.didReceiveAttrs(...arguments);
|
||||
if (this.inProgressUploads?.length > 0) {
|
||||
this.uppyUpload.uppyWrapper.uppyInstance?.cancelAll();
|
||||
}
|
||||
|
||||
this.set(
|
||||
"uploads",
|
||||
this.existingUploads ? cloneJSON(this.existingUploads) : []
|
||||
);
|
||||
}
|
||||
|
||||
didInsertElement() {
|
||||
super.didInsertElement(...arguments);
|
||||
|
||||
this.composerInputEl?.addEventListener("paste", this._pasteEventListener);
|
||||
}
|
||||
|
||||
willDestroyElement() {
|
||||
super.willDestroyElement(...arguments);
|
||||
|
||||
this.composerInputEl?.removeEventListener(
|
||||
"paste",
|
||||
this._pasteEventListener
|
||||
);
|
||||
}
|
||||
|
||||
get showUploadsContainer() {
|
||||
return this.get("uploads.length") > 0 || this.inProgressUploads.length > 0;
|
||||
}
|
||||
|
||||
@action
|
||||
cancelUploading(upload) {
|
||||
this.uppyUpload.cancelSingleUpload({
|
||||
fileId: upload.id,
|
||||
});
|
||||
this.removeUpload(upload);
|
||||
}
|
||||
|
||||
@action
|
||||
removeUpload(upload) {
|
||||
this.uploads.removeObject(upload);
|
||||
this._triggerUploadsChanged();
|
||||
}
|
||||
|
||||
@bind
|
||||
_pasteEventListener(event) {
|
||||
if (document.activeElement !== this.composerInputEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { canUpload, canPasteHtml, types } = clipboardHelpers(event, {
|
||||
siteSettings: this.siteSettings,
|
||||
canUpload: true,
|
||||
});
|
||||
|
||||
if (!canUpload || canPasteHtml || types.includes("text/plain")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event && event.clipboardData && event.clipboardData.files) {
|
||||
this.uppyUpload.addFiles([...event.clipboardData.files], {
|
||||
pasted: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_triggerUploadsChanged() {
|
||||
this.onUploadChanged?.(this.uploads, {
|
||||
inProgressUploadsCount: this.inProgressUploads?.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
{{#if this.showUploadsContainer}}
|
||||
<div class="chat-composer-uploads-container">
|
||||
{{#each this.uploads as |upload|}}
|
||||
|
@ -1,140 +0,0 @@
|
||||
import Component from "@ember/component";
|
||||
import { action } from "@ember/object";
|
||||
import { getOwner } from "@ember/owner";
|
||||
import { service } from "@ember/service";
|
||||
import { classNames } from "@ember-decorators/component";
|
||||
import { bind } from "discourse/lib/decorators";
|
||||
import { cloneJSON } from "discourse/lib/object";
|
||||
import UppyUpload from "discourse/lib/uppy/uppy-upload";
|
||||
import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin";
|
||||
import { clipboardHelpers } from "discourse/lib/utilities";
|
||||
|
||||
@classNames("chat-composer-uploads")
|
||||
export default class ChatComposerUploads extends Component {
|
||||
@service mediaOptimizationWorker;
|
||||
@service chatStateManager;
|
||||
|
||||
uppyUpload = new UppyUpload(getOwner(this), {
|
||||
id: "chat-composer-uploader",
|
||||
type: "chat-composer",
|
||||
useMultipartUploadsIfAvailable: true,
|
||||
|
||||
uppyReady: () => {
|
||||
if (this.siteSettings.composer_media_optimization_image_enabled) {
|
||||
this.uppyUpload.uppyWrapper.useUploadPlugin(UppyMediaOptimization, {
|
||||
optimizeFn: (data, opts) =>
|
||||
this.mediaOptimizationWorker.optimizeImage(data, opts),
|
||||
runParallel: !this.site.isMobileDevice,
|
||||
});
|
||||
}
|
||||
|
||||
this.uppyUpload.uppyWrapper.onPreProcessProgress((file) => {
|
||||
const inProgressUpload = this.inProgressUploads.findBy("id", file.id);
|
||||
if (!inProgressUpload?.processing) {
|
||||
inProgressUpload?.set("processing", true);
|
||||
}
|
||||
});
|
||||
|
||||
this.uppyUpload.uppyWrapper.onPreProcessComplete((file) => {
|
||||
const inProgressUpload = this.inProgressUploads.findBy("id", file.id);
|
||||
inProgressUpload?.set("processing", false);
|
||||
});
|
||||
},
|
||||
|
||||
uploadDone: (upload) => {
|
||||
this.uploads.pushObject(upload);
|
||||
this._triggerUploadsChanged();
|
||||
},
|
||||
|
||||
uploadDropTargetOptions: () => ({
|
||||
target: this.uploadDropZone || document.body,
|
||||
}),
|
||||
|
||||
onProgressUploadsChanged: () => {
|
||||
this._triggerUploadsChanged(this.uploads, {
|
||||
inProgressUploadsCount: this.inProgressUploads?.length,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
existingUploads = null;
|
||||
uploads = null;
|
||||
uploadDropZone = null;
|
||||
|
||||
get inProgressUploads() {
|
||||
return this.uppyUpload.inProgressUploads;
|
||||
}
|
||||
|
||||
didReceiveAttrs() {
|
||||
super.didReceiveAttrs(...arguments);
|
||||
if (this.inProgressUploads?.length > 0) {
|
||||
this.uppyUpload.uppyWrapper.uppyInstance?.cancelAll();
|
||||
}
|
||||
|
||||
this.set(
|
||||
"uploads",
|
||||
this.existingUploads ? cloneJSON(this.existingUploads) : []
|
||||
);
|
||||
}
|
||||
|
||||
didInsertElement() {
|
||||
super.didInsertElement(...arguments);
|
||||
|
||||
this.composerInputEl?.addEventListener("paste", this._pasteEventListener);
|
||||
}
|
||||
|
||||
willDestroyElement() {
|
||||
super.willDestroyElement(...arguments);
|
||||
|
||||
this.composerInputEl?.removeEventListener(
|
||||
"paste",
|
||||
this._pasteEventListener
|
||||
);
|
||||
}
|
||||
|
||||
get showUploadsContainer() {
|
||||
return this.get("uploads.length") > 0 || this.inProgressUploads.length > 0;
|
||||
}
|
||||
|
||||
@action
|
||||
cancelUploading(upload) {
|
||||
this.uppyUpload.cancelSingleUpload({
|
||||
fileId: upload.id,
|
||||
});
|
||||
this.removeUpload(upload);
|
||||
}
|
||||
|
||||
@action
|
||||
removeUpload(upload) {
|
||||
this.uploads.removeObject(upload);
|
||||
this._triggerUploadsChanged();
|
||||
}
|
||||
|
||||
@bind
|
||||
_pasteEventListener(event) {
|
||||
if (document.activeElement !== this.composerInputEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { canUpload, canPasteHtml, types } = clipboardHelpers(event, {
|
||||
siteSettings: this.siteSettings,
|
||||
canUpload: true,
|
||||
});
|
||||
|
||||
if (!canUpload || canPasteHtml || types.includes("text/plain")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event && event.clipboardData && event.clipboardData.files) {
|
||||
this.uppyUpload.addFiles([...event.clipboardData.files], {
|
||||
pasted: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_triggerUploadsChanged() {
|
||||
this.onUploadChanged?.(this.uploads, {
|
||||
inProgressUploadsCount: this.inProgressUploads?.length,
|
||||
});
|
||||
}
|
||||
}
|
@ -1,3 +1,664 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { getOwner } from "@ember/owner";
|
||||
import { cancel, next } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { isPresent } from "@ember/utils";
|
||||
import $ from "jquery";
|
||||
import {
|
||||
emojiSearch,
|
||||
isSkinTonableEmoji,
|
||||
normalizeEmoji,
|
||||
} from "pretty-text/emoji";
|
||||
import { replacements, translations } from "pretty-text/emoji/data";
|
||||
import { Promise } from "rsvp";
|
||||
import EmojiPickerDetached from "discourse/components/emoji-picker/detached";
|
||||
import InsertHyperlink from "discourse/components/modal/insert-hyperlink";
|
||||
import { SKIP } from "discourse/lib/autocomplete";
|
||||
import renderEmojiAutocomplete from "discourse/lib/autocomplete/emoji";
|
||||
import userAutocomplete from "discourse/lib/autocomplete/user";
|
||||
import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete";
|
||||
import loadEmojiSearchAliases from "discourse/lib/load-emoji-search-aliases";
|
||||
import { cloneJSON } from "discourse/lib/object";
|
||||
import { emojiUrlFor } from "discourse/lib/text";
|
||||
import userSearch from "discourse/lib/user-search";
|
||||
import {
|
||||
destroyUserStatuses,
|
||||
initUserStatusHtml,
|
||||
renderUserStatusHtml,
|
||||
} from "discourse/lib/user-status-on-autocomplete";
|
||||
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
|
||||
import { waitForClosedKeyboard } from "discourse/lib/wait-for-keyboard";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import { chatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons";
|
||||
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
|
||||
import TextareaInteractor from "discourse/plugins/chat/discourse/lib/textarea-interactor";
|
||||
|
||||
const CHAT_PRESENCE_KEEP_ALIVE = 5 * 1000; // 5 seconds
|
||||
|
||||
export default class ChatComposer extends Component {
|
||||
@service capabilities;
|
||||
@service site;
|
||||
@service siteSettings;
|
||||
@service store;
|
||||
@service chat;
|
||||
@service composerPresenceManager;
|
||||
@service chatComposerWarningsTracker;
|
||||
@service appEvents;
|
||||
@service emojiStore;
|
||||
@service currentUser;
|
||||
@service chatApi;
|
||||
@service chatDraftsManager;
|
||||
@service modal;
|
||||
@service menu;
|
||||
|
||||
@tracked isFocused = false;
|
||||
@tracked inProgressUploadsCount = 0;
|
||||
@tracked presenceChannelName;
|
||||
|
||||
get shouldRenderMessageDetails() {
|
||||
return (
|
||||
this.draft?.editing ||
|
||||
(this.context === "channel" && this.draft?.inReplyTo)
|
||||
);
|
||||
}
|
||||
|
||||
get inlineButtons() {
|
||||
return chatComposerButtons(this, "inline", this.context);
|
||||
}
|
||||
|
||||
get dropdownButtons() {
|
||||
return chatComposerButtons(this, "dropdown", this.context);
|
||||
}
|
||||
|
||||
get fileUploadElementId() {
|
||||
return this.context + "-file-uploader";
|
||||
}
|
||||
|
||||
get canAttachUploads() {
|
||||
return (
|
||||
this.siteSettings.chat_allow_uploads &&
|
||||
isPresent(this.args.uploadDropZone)
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
persistDraft() {}
|
||||
|
||||
@action
|
||||
setupAutocomplete(textarea) {
|
||||
const $textarea = $(textarea);
|
||||
this.#applyUserAutocomplete($textarea);
|
||||
this.#applyEmojiAutocomplete($textarea);
|
||||
this.#applyCategoryHashtagAutocomplete($textarea);
|
||||
}
|
||||
|
||||
@action
|
||||
setupTextareaInteractor(textarea) {
|
||||
this.composer.textarea = new TextareaInteractor(getOwner(this), textarea);
|
||||
|
||||
if (this.site.desktopView && this.args.autofocus) {
|
||||
this.composer.focus({ ensureAtEnd: true, refreshHeight: true });
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
didUpdateMessage() {
|
||||
this.cancelPersistDraft();
|
||||
this.composer.textarea.value = this.draft.message;
|
||||
this.persistDraft();
|
||||
this.captureMentions({ skipDebounce: true });
|
||||
}
|
||||
|
||||
@action
|
||||
didUpdateInReplyTo() {
|
||||
this.cancelPersistDraft();
|
||||
this.persistDraft();
|
||||
}
|
||||
|
||||
@action
|
||||
cancelPersistDraft() {
|
||||
cancel(this._persistHandler);
|
||||
}
|
||||
|
||||
@action
|
||||
handleInlineButtonAction(buttonAction, event) {
|
||||
event.stopPropagation();
|
||||
|
||||
buttonAction();
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
const minLength = this.siteSettings.chat_minimum_message_length || 1;
|
||||
return (
|
||||
this.draft?.message?.length >= minLength ||
|
||||
(this.canAttachUploads && this.hasUploads)
|
||||
);
|
||||
}
|
||||
|
||||
get hasUploads() {
|
||||
return this.draft?.uploads?.length > 0;
|
||||
}
|
||||
|
||||
get sendEnabled() {
|
||||
return (
|
||||
(this.hasContent || this.draft?.editing) &&
|
||||
!this.pane.sending &&
|
||||
!this.inProgressUploadsCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
setup() {
|
||||
this.composer.scroller = this.args.scroller;
|
||||
this.appEvents.on("chat:modify-selection", this, "modifySelection");
|
||||
this.appEvents.on(
|
||||
"chat:open-insert-link-modal",
|
||||
this,
|
||||
"openInsertLinkModal"
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
teardown() {
|
||||
this.appEvents.off("chat:modify-selection", this, "modifySelection");
|
||||
this.appEvents.off(
|
||||
"chat:open-insert-link-modal",
|
||||
this,
|
||||
"openInsertLinkModal"
|
||||
);
|
||||
this.pane.sending = false;
|
||||
}
|
||||
|
||||
@action
|
||||
insertDiscourseLocalDate() {
|
||||
// JIT import because local-dates isn't necessarily enabled
|
||||
const LocalDatesCreateModal =
|
||||
require("discourse/plugins/discourse-local-dates/discourse/components/modal/local-dates-create").default;
|
||||
|
||||
this.modal.show(LocalDatesCreateModal, {
|
||||
model: {
|
||||
insertDate: (markup) => {
|
||||
this.composer.textarea.addText(
|
||||
this.composer.textarea.getSelected(),
|
||||
markup
|
||||
);
|
||||
this.composer.focus();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
uploadClicked() {
|
||||
document.querySelector(`#${this.fileUploadElementId}`).click();
|
||||
}
|
||||
|
||||
@action
|
||||
computeIsFocused(isFocused) {
|
||||
next(() => {
|
||||
this.isFocused = isFocused;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onInput(event) {
|
||||
this.draft.draftSaved = false;
|
||||
this.draft.message = event.target.value;
|
||||
this.composer.textarea.refreshHeight();
|
||||
this.reportReplyingPresence();
|
||||
this.persistDraft();
|
||||
this.captureMentions();
|
||||
}
|
||||
|
||||
@action
|
||||
onUploadChanged(uploads, { inProgressUploadsCount }) {
|
||||
this.draft.draftSaved = false;
|
||||
|
||||
this.inProgressUploadsCount = inProgressUploadsCount || 0;
|
||||
|
||||
if (
|
||||
typeof uploads !== "undefined" &&
|
||||
inProgressUploadsCount !== "undefined" &&
|
||||
inProgressUploadsCount === 0 &&
|
||||
this.draft
|
||||
) {
|
||||
this.draft.uploads = cloneJSON(uploads);
|
||||
}
|
||||
|
||||
this.composer.textarea?.focus();
|
||||
this.reportReplyingPresence();
|
||||
this.persistDraft();
|
||||
}
|
||||
|
||||
@action
|
||||
trapMouseDown(event) {
|
||||
event?.preventDefault();
|
||||
}
|
||||
|
||||
@action
|
||||
async onSend(event) {
|
||||
if (!this.sendEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
event?.preventDefault();
|
||||
|
||||
if (
|
||||
this.draft.editing &&
|
||||
!this.hasUploads &&
|
||||
this.draft.message.length === 0
|
||||
) {
|
||||
this.#deleteEmptyMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.reactingToLastMessage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.args.onSendMessage(this.draft);
|
||||
this.composer.textarea.refreshHeight();
|
||||
}
|
||||
|
||||
async reactingToLastMessage() {
|
||||
// Check if the message is a reaction to the latest message in the channel.
|
||||
const message = this.draft.message.trim();
|
||||
let reactionCode = "";
|
||||
if (message.startsWith("+")) {
|
||||
const reaction = message.substring(1);
|
||||
// First check if the message is +{emoji}
|
||||
if (replacements[reaction]) {
|
||||
reactionCode = replacements[reaction];
|
||||
} else {
|
||||
// Then check if the message is +:{emoji_code}:
|
||||
const emojiCode = reaction.substring(1, reaction.length - 1);
|
||||
reactionCode = normalizeEmoji(emojiCode);
|
||||
}
|
||||
}
|
||||
|
||||
if (reactionCode && this.lastMessage?.id) {
|
||||
const interactor = new ChatMessageInteractor(
|
||||
getOwner(this),
|
||||
this.lastMessage,
|
||||
this.context
|
||||
);
|
||||
|
||||
await interactor.react(reactionCode, "add");
|
||||
this.resetDraft();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
reportReplyingPresence() {
|
||||
if (!this.args.channel || !this.draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.composerPresenceManager.notifyState(
|
||||
this.presenceChannelName,
|
||||
!this.draft.editing && this.hasContent,
|
||||
CHAT_PRESENCE_KEEP_ALIVE
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
modifySelection(event, options = { type: null, context: null }) {
|
||||
if (options.context !== this.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = this.composer.textarea.getSelected("", { lineVal: true });
|
||||
if (options.type === "bold") {
|
||||
this.composer.textarea.applySurround(sel, "**", "**", "bold_text");
|
||||
} else if (options.type === "italic") {
|
||||
this.composer.textarea.applySurround(sel, "_", "_", "italic_text");
|
||||
} else if (options.type === "code") {
|
||||
this.composer.textarea.applySurround(sel, "`", "`", "code_text");
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
onTextareaFocusOut() {
|
||||
this.isFocused = false;
|
||||
}
|
||||
|
||||
@action
|
||||
onTextareaFocusIn(event) {
|
||||
this.isFocused = true;
|
||||
|
||||
if (!this.capabilities.isIOS) {
|
||||
return;
|
||||
}
|
||||
|
||||
// hack to prevent the whole viewport to move on focus input
|
||||
const textarea = event.target;
|
||||
textarea.style.transform = "translateY(-99999px)";
|
||||
textarea.focus();
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
textarea.style.transform = "";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onKeyDown(event) {
|
||||
if (
|
||||
this.site.mobileView ||
|
||||
event.altKey ||
|
||||
this.#isAutocompleteDisplayed()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape" && !event.shiftKey) {
|
||||
return this.handleEscape(event);
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
const shortcutPreference =
|
||||
this.currentUser.user_option.chat_send_shortcut;
|
||||
const send =
|
||||
(shortcutPreference === "enter" && !event.shiftKey) ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey;
|
||||
|
||||
if (!send) {
|
||||
// insert newline
|
||||
return;
|
||||
}
|
||||
|
||||
this.onSend();
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" && !this.hasContent && !this.draft.editing) {
|
||||
if (event.shiftKey && this.lastMessage?.replyable) {
|
||||
this.composer.replyTo(this.lastMessage);
|
||||
} else {
|
||||
const editableMessage = this.lastUserMessage(this.currentUser);
|
||||
if (editableMessage?.editable) {
|
||||
this.composer.edit(editableMessage);
|
||||
this.args.channel.draft = editableMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
openInsertLinkModal(event, options = { context: null }) {
|
||||
if (options.context !== this.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = this.composer.textarea.getSelected("", { lineVal: true });
|
||||
const linkText = selected?.value;
|
||||
this.modal.show(InsertHyperlink, {
|
||||
model: {
|
||||
linkText,
|
||||
toolbarEvent: {
|
||||
addText: (text) => this.composer.textarea.addText(selected, text),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onSelectEmoji(emoji) {
|
||||
this.composer.textarea.emojiSelected(emoji);
|
||||
|
||||
if (this.site.desktopView) {
|
||||
this.composer.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
captureMentions(opts = { skipDebounce: false }) {
|
||||
if (this.hasContent) {
|
||||
this.chatComposerWarningsTracker.trackMentions(
|
||||
this.draft,
|
||||
opts.skipDebounce
|
||||
);
|
||||
} else {
|
||||
this.chatComposerWarningsTracker.reset();
|
||||
}
|
||||
}
|
||||
|
||||
#addMentionedUser(userData) {
|
||||
const user = this.store.createRecord("user", userData);
|
||||
this.draft.mentionedUsers.set(user.id, user);
|
||||
}
|
||||
|
||||
#applyUserAutocomplete($textarea) {
|
||||
if (!this.siteSettings.enable_mentions) {
|
||||
return;
|
||||
}
|
||||
|
||||
$textarea.autocomplete({
|
||||
template: userAutocomplete,
|
||||
key: "@",
|
||||
width: "100%",
|
||||
treatAsTextarea: true,
|
||||
autoSelectFirstSuggestion: true,
|
||||
transformComplete: (obj) => {
|
||||
if (obj.isUser) {
|
||||
this.#addMentionedUser(obj);
|
||||
}
|
||||
|
||||
return obj.username || obj.name;
|
||||
},
|
||||
dataSource: (term) => {
|
||||
destroyUserStatuses();
|
||||
return userSearch({ term, includeGroups: true }).then((result) => {
|
||||
if (result?.users?.length > 0) {
|
||||
const presentUserNames =
|
||||
this.chat.presenceChannel.users?.mapBy("username");
|
||||
result.users.forEach((user) => {
|
||||
if (presentUserNames.includes(user.username)) {
|
||||
user.cssClasses = "is-online";
|
||||
}
|
||||
});
|
||||
initUserStatusHtml(getOwner(this), result.users);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
},
|
||||
onRender: (options) => {
|
||||
renderUserStatusHtml(options);
|
||||
},
|
||||
afterComplete: (text, event) => {
|
||||
event.preventDefault();
|
||||
this.composer.textarea.value = text;
|
||||
this.composer.focus();
|
||||
this.captureMentions();
|
||||
},
|
||||
onClose: destroyUserStatuses,
|
||||
});
|
||||
}
|
||||
|
||||
#applyCategoryHashtagAutocomplete($textarea) {
|
||||
setupHashtagAutocomplete(
|
||||
this.site.hashtag_configurations["chat-composer"],
|
||||
$textarea,
|
||||
this.siteSettings,
|
||||
{
|
||||
treatAsTextarea: true,
|
||||
afterComplete: (text, event) => {
|
||||
event.preventDefault();
|
||||
this.composer.textarea.value = text;
|
||||
this.composer.focus();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#applyEmojiAutocomplete($textarea) {
|
||||
if (!this.siteSettings.enable_emoji) {
|
||||
return;
|
||||
}
|
||||
|
||||
$textarea.autocomplete({
|
||||
template: renderEmojiAutocomplete,
|
||||
key: ":",
|
||||
afterComplete: (text, event) => {
|
||||
event.preventDefault();
|
||||
this.composer.textarea.value = text;
|
||||
this.composer.focus();
|
||||
},
|
||||
treatAsTextarea: true,
|
||||
onKeyUp: (text, cp) => {
|
||||
const matches =
|
||||
/(?:^|[\s.\?,@\/#!%&*;:\[\]{}=\-_()+])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec(
|
||||
text.substring(0, cp)
|
||||
);
|
||||
|
||||
if (matches && matches[1]) {
|
||||
return [matches[1]];
|
||||
}
|
||||
},
|
||||
transformComplete: async (v) => {
|
||||
if (v.code) {
|
||||
return `${v.code}:`;
|
||||
} else {
|
||||
$textarea.autocomplete({ cancel: true });
|
||||
|
||||
const menuOptions = {
|
||||
identifier: "emoji-picker",
|
||||
groupIdentifier: "emoji-picker",
|
||||
component: EmojiPickerDetached,
|
||||
context: "chat",
|
||||
modalForMobile: true,
|
||||
data: {
|
||||
didSelectEmoji: (emoji) => {
|
||||
this.onSelectEmoji(emoji);
|
||||
},
|
||||
term: v.term,
|
||||
context: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
// Close the keyboard before showing the emoji picker
|
||||
// it avoids a whole range of bugs on iOS
|
||||
await waitForClosedKeyboard(this);
|
||||
|
||||
const virtualElement = virtualElementFromTextRange();
|
||||
this.menuInstance = await this.menu.show(virtualElement, menuOptions);
|
||||
return "";
|
||||
}
|
||||
},
|
||||
dataSource: (term) => {
|
||||
return new Promise((resolve) => {
|
||||
const full = `:${term}`;
|
||||
term = term.toLowerCase();
|
||||
|
||||
// We need to avoid quick emoji autocomplete cause it can interfere with quick
|
||||
// typing, set minimal length to 2
|
||||
let minLength = Math.max(
|
||||
this.siteSettings.emoji_autocomplete_min_chars,
|
||||
2
|
||||
);
|
||||
|
||||
if (term.length < minLength) {
|
||||
return resolve(SKIP);
|
||||
}
|
||||
|
||||
// bypass :-p and other common typed smileys
|
||||
if (
|
||||
!term.match(
|
||||
/[^-\{\}\[\]\(\)\*_\<\>\\\/].*[^-\{\}\[\]\(\)\*_\<\>\\\/]/
|
||||
)
|
||||
) {
|
||||
return resolve(SKIP);
|
||||
}
|
||||
|
||||
if (term === "") {
|
||||
const favorites = this.emojiStore.favoritesForContext("chat");
|
||||
if (favorites.length > 0) {
|
||||
return resolve(favorites.slice(0, 5));
|
||||
} else {
|
||||
return resolve([
|
||||
"slight_smile",
|
||||
"smile",
|
||||
"wink",
|
||||
"sunny",
|
||||
"blush",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// note this will only work for emojis starting with :
|
||||
// eg: :-)
|
||||
const emojiTranslation = this.site.custom_emoji_translation || {};
|
||||
const allTranslations = Object.assign(
|
||||
{},
|
||||
translations,
|
||||
emojiTranslation
|
||||
);
|
||||
if (allTranslations[full]) {
|
||||
return resolve([allTranslations[full]]);
|
||||
}
|
||||
|
||||
const emojiDenied = this.site.denied_emojis || [];
|
||||
const match = term.match(/^:?(.*?):t([2-6])?$/);
|
||||
if (match) {
|
||||
const name = match[1];
|
||||
const scale = match[2];
|
||||
|
||||
if (isSkinTonableEmoji(name) && !emojiDenied.includes(name)) {
|
||||
if (scale) {
|
||||
return resolve([`${name}:t${scale}`]);
|
||||
} else {
|
||||
return resolve([2, 3, 4, 5, 6].map((x) => `${name}:t${x}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadEmojiSearchAliases().then((searchAliases) => {
|
||||
const options = emojiSearch(term, {
|
||||
maxResults: 5,
|
||||
diversity: this.emojiStore.diversity,
|
||||
exclude: emojiDenied,
|
||||
searchAliases,
|
||||
});
|
||||
|
||||
resolve(options);
|
||||
});
|
||||
})
|
||||
.then((list) => {
|
||||
if (list === SKIP) {
|
||||
return;
|
||||
}
|
||||
return list.map((code) => ({ code, src: emojiUrlFor(code) }));
|
||||
})
|
||||
.then((list) => {
|
||||
if (list?.length) {
|
||||
list.push({ label: i18n("composer.more_emoji"), term });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#isAutocompleteDisplayed() {
|
||||
return document.querySelector(".autocomplete");
|
||||
}
|
||||
|
||||
#deleteEmptyMessage() {
|
||||
new ChatMessageInteractor(
|
||||
getOwner(this),
|
||||
this.draft,
|
||||
this.context
|
||||
).delete();
|
||||
this.resetDraft();
|
||||
}
|
||||
}
|
||||
|
||||
{{! template-lint-disable no-pointer-down-event-binding }}
|
||||
{{! template-lint-disable no-invalid-interactive }}
|
||||
|
||||
|
@ -1,660 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { getOwner } from "@ember/owner";
|
||||
import { cancel, next } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { isPresent } from "@ember/utils";
|
||||
import $ from "jquery";
|
||||
import {
|
||||
emojiSearch,
|
||||
isSkinTonableEmoji,
|
||||
normalizeEmoji,
|
||||
} from "pretty-text/emoji";
|
||||
import { replacements, translations } from "pretty-text/emoji/data";
|
||||
import { Promise } from "rsvp";
|
||||
import EmojiPickerDetached from "discourse/components/emoji-picker/detached";
|
||||
import InsertHyperlink from "discourse/components/modal/insert-hyperlink";
|
||||
import { SKIP } from "discourse/lib/autocomplete";
|
||||
import renderEmojiAutocomplete from "discourse/lib/autocomplete/emoji";
|
||||
import userAutocomplete from "discourse/lib/autocomplete/user";
|
||||
import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete";
|
||||
import loadEmojiSearchAliases from "discourse/lib/load-emoji-search-aliases";
|
||||
import { cloneJSON } from "discourse/lib/object";
|
||||
import { emojiUrlFor } from "discourse/lib/text";
|
||||
import userSearch from "discourse/lib/user-search";
|
||||
import {
|
||||
destroyUserStatuses,
|
||||
initUserStatusHtml,
|
||||
renderUserStatusHtml,
|
||||
} from "discourse/lib/user-status-on-autocomplete";
|
||||
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
|
||||
import { waitForClosedKeyboard } from "discourse/lib/wait-for-keyboard";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import { chatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons";
|
||||
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
|
||||
import TextareaInteractor from "discourse/plugins/chat/discourse/lib/textarea-interactor";
|
||||
|
||||
const CHAT_PRESENCE_KEEP_ALIVE = 5 * 1000; // 5 seconds
|
||||
|
||||
export default class ChatComposer extends Component {
|
||||
@service capabilities;
|
||||
@service site;
|
||||
@service siteSettings;
|
||||
@service store;
|
||||
@service chat;
|
||||
@service composerPresenceManager;
|
||||
@service chatComposerWarningsTracker;
|
||||
@service appEvents;
|
||||
@service emojiStore;
|
||||
@service currentUser;
|
||||
@service chatApi;
|
||||
@service chatDraftsManager;
|
||||
@service modal;
|
||||
@service menu;
|
||||
|
||||
@tracked isFocused = false;
|
||||
@tracked inProgressUploadsCount = 0;
|
||||
@tracked presenceChannelName;
|
||||
|
||||
get shouldRenderMessageDetails() {
|
||||
return (
|
||||
this.draft?.editing ||
|
||||
(this.context === "channel" && this.draft?.inReplyTo)
|
||||
);
|
||||
}
|
||||
|
||||
get inlineButtons() {
|
||||
return chatComposerButtons(this, "inline", this.context);
|
||||
}
|
||||
|
||||
get dropdownButtons() {
|
||||
return chatComposerButtons(this, "dropdown", this.context);
|
||||
}
|
||||
|
||||
get fileUploadElementId() {
|
||||
return this.context + "-file-uploader";
|
||||
}
|
||||
|
||||
get canAttachUploads() {
|
||||
return (
|
||||
this.siteSettings.chat_allow_uploads &&
|
||||
isPresent(this.args.uploadDropZone)
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
persistDraft() {}
|
||||
|
||||
@action
|
||||
setupAutocomplete(textarea) {
|
||||
const $textarea = $(textarea);
|
||||
this.#applyUserAutocomplete($textarea);
|
||||
this.#applyEmojiAutocomplete($textarea);
|
||||
this.#applyCategoryHashtagAutocomplete($textarea);
|
||||
}
|
||||
|
||||
@action
|
||||
setupTextareaInteractor(textarea) {
|
||||
this.composer.textarea = new TextareaInteractor(getOwner(this), textarea);
|
||||
|
||||
if (this.site.desktopView && this.args.autofocus) {
|
||||
this.composer.focus({ ensureAtEnd: true, refreshHeight: true });
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
didUpdateMessage() {
|
||||
this.cancelPersistDraft();
|
||||
this.composer.textarea.value = this.draft.message;
|
||||
this.persistDraft();
|
||||
this.captureMentions({ skipDebounce: true });
|
||||
}
|
||||
|
||||
@action
|
||||
didUpdateInReplyTo() {
|
||||
this.cancelPersistDraft();
|
||||
this.persistDraft();
|
||||
}
|
||||
|
||||
@action
|
||||
cancelPersistDraft() {
|
||||
cancel(this._persistHandler);
|
||||
}
|
||||
|
||||
@action
|
||||
handleInlineButtonAction(buttonAction, event) {
|
||||
event.stopPropagation();
|
||||
|
||||
buttonAction();
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
const minLength = this.siteSettings.chat_minimum_message_length || 1;
|
||||
return (
|
||||
this.draft?.message?.length >= minLength ||
|
||||
(this.canAttachUploads && this.hasUploads)
|
||||
);
|
||||
}
|
||||
|
||||
get hasUploads() {
|
||||
return this.draft?.uploads?.length > 0;
|
||||
}
|
||||
|
||||
get sendEnabled() {
|
||||
return (
|
||||
(this.hasContent || this.draft?.editing) &&
|
||||
!this.pane.sending &&
|
||||
!this.inProgressUploadsCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
setup() {
|
||||
this.composer.scroller = this.args.scroller;
|
||||
this.appEvents.on("chat:modify-selection", this, "modifySelection");
|
||||
this.appEvents.on(
|
||||
"chat:open-insert-link-modal",
|
||||
this,
|
||||
"openInsertLinkModal"
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
teardown() {
|
||||
this.appEvents.off("chat:modify-selection", this, "modifySelection");
|
||||
this.appEvents.off(
|
||||
"chat:open-insert-link-modal",
|
||||
this,
|
||||
"openInsertLinkModal"
|
||||
);
|
||||
this.pane.sending = false;
|
||||
}
|
||||
|
||||
@action
|
||||
insertDiscourseLocalDate() {
|
||||
// JIT import because local-dates isn't necessarily enabled
|
||||
const LocalDatesCreateModal =
|
||||
require("discourse/plugins/discourse-local-dates/discourse/components/modal/local-dates-create").default;
|
||||
|
||||
this.modal.show(LocalDatesCreateModal, {
|
||||
model: {
|
||||
insertDate: (markup) => {
|
||||
this.composer.textarea.addText(
|
||||
this.composer.textarea.getSelected(),
|
||||
markup
|
||||
);
|
||||
this.composer.focus();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
uploadClicked() {
|
||||
document.querySelector(`#${this.fileUploadElementId}`).click();
|
||||
}
|
||||
|
||||
@action
|
||||
computeIsFocused(isFocused) {
|
||||
next(() => {
|
||||
this.isFocused = isFocused;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onInput(event) {
|
||||
this.draft.draftSaved = false;
|
||||
this.draft.message = event.target.value;
|
||||
this.composer.textarea.refreshHeight();
|
||||
this.reportReplyingPresence();
|
||||
this.persistDraft();
|
||||
this.captureMentions();
|
||||
}
|
||||
|
||||
@action
|
||||
onUploadChanged(uploads, { inProgressUploadsCount }) {
|
||||
this.draft.draftSaved = false;
|
||||
|
||||
this.inProgressUploadsCount = inProgressUploadsCount || 0;
|
||||
|
||||
if (
|
||||
typeof uploads !== "undefined" &&
|
||||
inProgressUploadsCount !== "undefined" &&
|
||||
inProgressUploadsCount === 0 &&
|
||||
this.draft
|
||||
) {
|
||||
this.draft.uploads = cloneJSON(uploads);
|
||||
}
|
||||
|
||||
this.composer.textarea?.focus();
|
||||
this.reportReplyingPresence();
|
||||
this.persistDraft();
|
||||
}
|
||||
|
||||
@action
|
||||
trapMouseDown(event) {
|
||||
event?.preventDefault();
|
||||
}
|
||||
|
||||
@action
|
||||
async onSend(event) {
|
||||
if (!this.sendEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
event?.preventDefault();
|
||||
|
||||
if (
|
||||
this.draft.editing &&
|
||||
!this.hasUploads &&
|
||||
this.draft.message.length === 0
|
||||
) {
|
||||
this.#deleteEmptyMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.reactingToLastMessage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.args.onSendMessage(this.draft);
|
||||
this.composer.textarea.refreshHeight();
|
||||
}
|
||||
|
||||
async reactingToLastMessage() {
|
||||
// Check if the message is a reaction to the latest message in the channel.
|
||||
const message = this.draft.message.trim();
|
||||
let reactionCode = "";
|
||||
if (message.startsWith("+")) {
|
||||
const reaction = message.substring(1);
|
||||
// First check if the message is +{emoji}
|
||||
if (replacements[reaction]) {
|
||||
reactionCode = replacements[reaction];
|
||||
} else {
|
||||
// Then check if the message is +:{emoji_code}:
|
||||
const emojiCode = reaction.substring(1, reaction.length - 1);
|
||||
reactionCode = normalizeEmoji(emojiCode);
|
||||
}
|
||||
}
|
||||
|
||||
if (reactionCode && this.lastMessage?.id) {
|
||||
const interactor = new ChatMessageInteractor(
|
||||
getOwner(this),
|
||||
this.lastMessage,
|
||||
this.context
|
||||
);
|
||||
|
||||
await interactor.react(reactionCode, "add");
|
||||
this.resetDraft();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
reportReplyingPresence() {
|
||||
if (!this.args.channel || !this.draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.composerPresenceManager.notifyState(
|
||||
this.presenceChannelName,
|
||||
!this.draft.editing && this.hasContent,
|
||||
CHAT_PRESENCE_KEEP_ALIVE
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
modifySelection(event, options = { type: null, context: null }) {
|
||||
if (options.context !== this.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = this.composer.textarea.getSelected("", { lineVal: true });
|
||||
if (options.type === "bold") {
|
||||
this.composer.textarea.applySurround(sel, "**", "**", "bold_text");
|
||||
} else if (options.type === "italic") {
|
||||
this.composer.textarea.applySurround(sel, "_", "_", "italic_text");
|
||||
} else if (options.type === "code") {
|
||||
this.composer.textarea.applySurround(sel, "`", "`", "code_text");
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
onTextareaFocusOut() {
|
||||
this.isFocused = false;
|
||||
}
|
||||
|
||||
@action
|
||||
onTextareaFocusIn(event) {
|
||||
this.isFocused = true;
|
||||
|
||||
if (!this.capabilities.isIOS) {
|
||||
return;
|
||||
}
|
||||
|
||||
// hack to prevent the whole viewport to move on focus input
|
||||
const textarea = event.target;
|
||||
textarea.style.transform = "translateY(-99999px)";
|
||||
textarea.focus();
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
textarea.style.transform = "";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onKeyDown(event) {
|
||||
if (
|
||||
this.site.mobileView ||
|
||||
event.altKey ||
|
||||
this.#isAutocompleteDisplayed()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape" && !event.shiftKey) {
|
||||
return this.handleEscape(event);
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
const shortcutPreference =
|
||||
this.currentUser.user_option.chat_send_shortcut;
|
||||
const send =
|
||||
(shortcutPreference === "enter" && !event.shiftKey) ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey;
|
||||
|
||||
if (!send) {
|
||||
// insert newline
|
||||
return;
|
||||
}
|
||||
|
||||
this.onSend();
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" && !this.hasContent && !this.draft.editing) {
|
||||
if (event.shiftKey && this.lastMessage?.replyable) {
|
||||
this.composer.replyTo(this.lastMessage);
|
||||
} else {
|
||||
const editableMessage = this.lastUserMessage(this.currentUser);
|
||||
if (editableMessage?.editable) {
|
||||
this.composer.edit(editableMessage);
|
||||
this.args.channel.draft = editableMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
openInsertLinkModal(event, options = { context: null }) {
|
||||
if (options.context !== this.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = this.composer.textarea.getSelected("", { lineVal: true });
|
||||
const linkText = selected?.value;
|
||||
this.modal.show(InsertHyperlink, {
|
||||
model: {
|
||||
linkText,
|
||||
toolbarEvent: {
|
||||
addText: (text) => this.composer.textarea.addText(selected, text),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onSelectEmoji(emoji) {
|
||||
this.composer.textarea.emojiSelected(emoji);
|
||||
|
||||
if (this.site.desktopView) {
|
||||
this.composer.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
captureMentions(opts = { skipDebounce: false }) {
|
||||
if (this.hasContent) {
|
||||
this.chatComposerWarningsTracker.trackMentions(
|
||||
this.draft,
|
||||
opts.skipDebounce
|
||||
);
|
||||
} else {
|
||||
this.chatComposerWarningsTracker.reset();
|
||||
}
|
||||
}
|
||||
|
||||
#addMentionedUser(userData) {
|
||||
const user = this.store.createRecord("user", userData);
|
||||
this.draft.mentionedUsers.set(user.id, user);
|
||||
}
|
||||
|
||||
#applyUserAutocomplete($textarea) {
|
||||
if (!this.siteSettings.enable_mentions) {
|
||||
return;
|
||||
}
|
||||
|
||||
$textarea.autocomplete({
|
||||
template: userAutocomplete,
|
||||
key: "@",
|
||||
width: "100%",
|
||||
treatAsTextarea: true,
|
||||
autoSelectFirstSuggestion: true,
|
||||
transformComplete: (obj) => {
|
||||
if (obj.isUser) {
|
||||
this.#addMentionedUser(obj);
|
||||
}
|
||||
|
||||
return obj.username || obj.name;
|
||||
},
|
||||
dataSource: (term) => {
|
||||
destroyUserStatuses();
|
||||
return userSearch({ term, includeGroups: true }).then((result) => {
|
||||
if (result?.users?.length > 0) {
|
||||
const presentUserNames =
|
||||
this.chat.presenceChannel.users?.mapBy("username");
|
||||
result.users.forEach((user) => {
|
||||
if (presentUserNames.includes(user.username)) {
|
||||
user.cssClasses = "is-online";
|
||||
}
|
||||
});
|
||||
initUserStatusHtml(getOwner(this), result.users);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
},
|
||||
onRender: (options) => {
|
||||
renderUserStatusHtml(options);
|
||||
},
|
||||
afterComplete: (text, event) => {
|
||||
event.preventDefault();
|
||||
this.composer.textarea.value = text;
|
||||
this.composer.focus();
|
||||
this.captureMentions();
|
||||
},
|
||||
onClose: destroyUserStatuses,
|
||||
});
|
||||
}
|
||||
|
||||
#applyCategoryHashtagAutocomplete($textarea) {
|
||||
setupHashtagAutocomplete(
|
||||
this.site.hashtag_configurations["chat-composer"],
|
||||
$textarea,
|
||||
this.siteSettings,
|
||||
{
|
||||
treatAsTextarea: true,
|
||||
afterComplete: (text, event) => {
|
||||
event.preventDefault();
|
||||
this.composer.textarea.value = text;
|
||||
this.composer.focus();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#applyEmojiAutocomplete($textarea) {
|
||||
if (!this.siteSettings.enable_emoji) {
|
||||
return;
|
||||
}
|
||||
|
||||
$textarea.autocomplete({
|
||||
template: renderEmojiAutocomplete,
|
||||
key: ":",
|
||||
afterComplete: (text, event) => {
|
||||
event.preventDefault();
|
||||
this.composer.textarea.value = text;
|
||||
this.composer.focus();
|
||||
},
|
||||
treatAsTextarea: true,
|
||||
onKeyUp: (text, cp) => {
|
||||
const matches =
|
||||
/(?:^|[\s.\?,@\/#!%&*;:\[\]{}=\-_()+])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec(
|
||||
text.substring(0, cp)
|
||||
);
|
||||
|
||||
if (matches && matches[1]) {
|
||||
return [matches[1]];
|
||||
}
|
||||
},
|
||||
transformComplete: async (v) => {
|
||||
if (v.code) {
|
||||
return `${v.code}:`;
|
||||
} else {
|
||||
$textarea.autocomplete({ cancel: true });
|
||||
|
||||
const menuOptions = {
|
||||
identifier: "emoji-picker",
|
||||
groupIdentifier: "emoji-picker",
|
||||
component: EmojiPickerDetached,
|
||||
context: "chat",
|
||||
modalForMobile: true,
|
||||
data: {
|
||||
didSelectEmoji: (emoji) => {
|
||||
this.onSelectEmoji(emoji);
|
||||
},
|
||||
term: v.term,
|
||||
context: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
// Close the keyboard before showing the emoji picker
|
||||
// it avoids a whole range of bugs on iOS
|
||||
await waitForClosedKeyboard(this);
|
||||
|
||||
const virtualElement = virtualElementFromTextRange();
|
||||
this.menuInstance = await this.menu.show(virtualElement, menuOptions);
|
||||
return "";
|
||||
}
|
||||
},
|
||||
dataSource: (term) => {
|
||||
return new Promise((resolve) => {
|
||||
const full = `:${term}`;
|
||||
term = term.toLowerCase();
|
||||
|
||||
// We need to avoid quick emoji autocomplete cause it can interfere with quick
|
||||
// typing, set minimal length to 2
|
||||
let minLength = Math.max(
|
||||
this.siteSettings.emoji_autocomplete_min_chars,
|
||||
2
|
||||
);
|
||||
|
||||
if (term.length < minLength) {
|
||||
return resolve(SKIP);
|
||||
}
|
||||
|
||||
// bypass :-p and other common typed smileys
|
||||
if (
|
||||
!term.match(
|
||||
/[^-\{\}\[\]\(\)\*_\<\>\\\/].*[^-\{\}\[\]\(\)\*_\<\>\\\/]/
|
||||
)
|
||||
) {
|
||||
return resolve(SKIP);
|
||||
}
|
||||
|
||||
if (term === "") {
|
||||
const favorites = this.emojiStore.favoritesForContext("chat");
|
||||
if (favorites.length > 0) {
|
||||
return resolve(favorites.slice(0, 5));
|
||||
} else {
|
||||
return resolve([
|
||||
"slight_smile",
|
||||
"smile",
|
||||
"wink",
|
||||
"sunny",
|
||||
"blush",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// note this will only work for emojis starting with :
|
||||
// eg: :-)
|
||||
const emojiTranslation = this.site.custom_emoji_translation || {};
|
||||
const allTranslations = Object.assign(
|
||||
{},
|
||||
translations,
|
||||
emojiTranslation
|
||||
);
|
||||
if (allTranslations[full]) {
|
||||
return resolve([allTranslations[full]]);
|
||||
}
|
||||
|
||||
const emojiDenied = this.site.denied_emojis || [];
|
||||
const match = term.match(/^:?(.*?):t([2-6])?$/);
|
||||
if (match) {
|
||||
const name = match[1];
|
||||
const scale = match[2];
|
||||
|
||||
if (isSkinTonableEmoji(name) && !emojiDenied.includes(name)) {
|
||||
if (scale) {
|
||||
return resolve([`${name}:t${scale}`]);
|
||||
} else {
|
||||
return resolve([2, 3, 4, 5, 6].map((x) => `${name}:t${x}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadEmojiSearchAliases().then((searchAliases) => {
|
||||
const options = emojiSearch(term, {
|
||||
maxResults: 5,
|
||||
diversity: this.emojiStore.diversity,
|
||||
exclude: emojiDenied,
|
||||
searchAliases,
|
||||
});
|
||||
|
||||
resolve(options);
|
||||
});
|
||||
})
|
||||
.then((list) => {
|
||||
if (list === SKIP) {
|
||||
return;
|
||||
}
|
||||
return list.map((code) => ({ code, src: emojiUrlFor(code) }));
|
||||
})
|
||||
.then((list) => {
|
||||
if (list?.length) {
|
||||
list.push({ label: i18n("composer.more_emoji"), term });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#isAutocompleteDisplayed() {
|
||||
return document.querySelector(".autocomplete");
|
||||
}
|
||||
|
||||
#deleteEmptyMessage() {
|
||||
new ChatMessageInteractor(
|
||||
getOwner(this),
|
||||
this.draft,
|
||||
this.context
|
||||
).delete();
|
||||
this.resetDraft();
|
||||
}
|
||||
}
|
@ -1,3 +1,229 @@
|
||||
import Component from "@ember/component";
|
||||
import { action } from "@ember/object";
|
||||
import { cancel, next, throttle } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { tagName } from "@ember-decorators/component";
|
||||
import { observes } from "@ember-decorators/object";
|
||||
import { bind } from "discourse/lib/decorators";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import DiscourseURL from "discourse/lib/url";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
|
||||
@tagName("")
|
||||
export default class ChatDrawer extends Component {
|
||||
@service chat;
|
||||
@service router;
|
||||
@service chatDrawerSize;
|
||||
@service chatChannelsManager;
|
||||
@service chatStateManager;
|
||||
@service chatDrawerRouter;
|
||||
|
||||
loading = false;
|
||||
sizeTimer = null;
|
||||
rafTimer = null;
|
||||
hasUnreadMessages = false;
|
||||
drawerStyle = null;
|
||||
|
||||
didInsertElement() {
|
||||
super.didInsertElement(...arguments);
|
||||
|
||||
if (!this.chat.userCanChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._checkSize();
|
||||
this.appEvents.on("chat:open-url", this, "openURL");
|
||||
this.appEvents.on("chat:toggle-close", this, "close");
|
||||
this.appEvents.on("composer:closed", this, "_checkSize");
|
||||
this.appEvents.on("composer:opened", this, "_checkSize");
|
||||
this.appEvents.on("composer:resized", this, "_checkSize");
|
||||
this.appEvents.on("composer:div-resizing", this, "_dynamicCheckSize");
|
||||
window.addEventListener("resize", this._checkSize);
|
||||
this.appEvents.on(
|
||||
"composer:resize-started",
|
||||
this,
|
||||
"_startDynamicCheckSize"
|
||||
);
|
||||
this.appEvents.on("composer:resize-ended", this, "_clearDynamicCheckSize");
|
||||
|
||||
this.computeDrawerStyle();
|
||||
}
|
||||
|
||||
willDestroyElement() {
|
||||
super.willDestroyElement(...arguments);
|
||||
|
||||
if (!this.chat.userCanChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener("resize", this._checkSize);
|
||||
|
||||
if (this.appEvents) {
|
||||
this.appEvents.off("chat:open-url", this, "openURL");
|
||||
this.appEvents.off("chat:toggle-close", this, "close");
|
||||
this.appEvents.off("composer:closed", this, "_checkSize");
|
||||
this.appEvents.off("composer:opened", this, "_checkSize");
|
||||
this.appEvents.off("composer:resized", this, "_checkSize");
|
||||
this.appEvents.off("composer:div-resizing", this, "_dynamicCheckSize");
|
||||
this.appEvents.off(
|
||||
"composer:resize-started",
|
||||
this,
|
||||
"_startDynamicCheckSize"
|
||||
);
|
||||
this.appEvents.off(
|
||||
"composer:resize-ended",
|
||||
this,
|
||||
"_clearDynamicCheckSize"
|
||||
);
|
||||
}
|
||||
if (this.sizeTimer) {
|
||||
cancel(this.sizeTimer);
|
||||
this.sizeTimer = null;
|
||||
}
|
||||
if (this.rafTimer) {
|
||||
window.cancelAnimationFrame(this.rafTimer);
|
||||
}
|
||||
}
|
||||
|
||||
@observes("chatStateManager.isDrawerActive")
|
||||
_fireHiddenAppEvents() {
|
||||
this.appEvents.trigger("chat:rerender-header");
|
||||
}
|
||||
|
||||
computeDrawerStyle() {
|
||||
const { width, height } = this.chatDrawerSize.size;
|
||||
let style = `width: ${escapeExpression((width || "0").toString())}px;`;
|
||||
style += `height: ${escapeExpression((height || "0").toString())}px;`;
|
||||
this.set("drawerStyle", htmlSafe(style));
|
||||
}
|
||||
|
||||
get drawerActions() {
|
||||
return {
|
||||
openInFullPage: this.openInFullPage,
|
||||
close: this.close,
|
||||
toggleExpand: this.toggleExpand,
|
||||
};
|
||||
}
|
||||
|
||||
@bind
|
||||
_dynamicCheckSize() {
|
||||
if (!this.chatStateManager.isDrawerActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.rafTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rafTimer = window.requestAnimationFrame(() => {
|
||||
this.rafTimer = null;
|
||||
this._performCheckSize();
|
||||
});
|
||||
}
|
||||
|
||||
_startDynamicCheckSize() {
|
||||
if (!this.chatStateManager.isDrawerActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector(".chat-drawer-outlet-container")
|
||||
.classList.add("clear-transitions");
|
||||
}
|
||||
|
||||
_clearDynamicCheckSize() {
|
||||
if (!this.chatStateManager.isDrawerActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector(".chat-drawer-outlet-container")
|
||||
.classList.remove("clear-transitions");
|
||||
this._checkSize();
|
||||
}
|
||||
|
||||
@bind
|
||||
_checkSize() {
|
||||
this.sizeTimer = throttle(this, this._performCheckSize, 150);
|
||||
}
|
||||
|
||||
_performCheckSize() {
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawerContainer = document.querySelector(
|
||||
".chat-drawer-outlet-container"
|
||||
);
|
||||
if (!drawerContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const composer = document.getElementById("reply-control");
|
||||
const composerIsClosed = composer.classList.contains("closed");
|
||||
const minRightMargin = 15;
|
||||
|
||||
drawerContainer.style.setProperty(
|
||||
"--composer-right",
|
||||
(composerIsClosed
|
||||
? minRightMargin
|
||||
: Math.max(minRightMargin, composer.offsetLeft)) + "px"
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
openURL(url = null) {
|
||||
this.chat.activeChannel = null;
|
||||
this.chatDrawerRouter.stateFor(this._routeFromURL(url));
|
||||
this.chatStateManager.didOpenDrawer(url);
|
||||
}
|
||||
|
||||
_routeFromURL(url) {
|
||||
let route = this.router.recognize(getURL(url || "/"));
|
||||
|
||||
// ember might recognize the index subroute
|
||||
if (route.localName === "index") {
|
||||
route = route.parent;
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
@action
|
||||
async openInFullPage() {
|
||||
this.chatStateManager.storeAppURL();
|
||||
this.chatStateManager.prefersFullPage();
|
||||
this.chat.activeChannel = null;
|
||||
|
||||
await new Promise((resolve) => next(resolve));
|
||||
|
||||
return DiscourseURL.routeTo(this.chatStateManager.lastKnownChatURL);
|
||||
}
|
||||
|
||||
@action
|
||||
toggleExpand() {
|
||||
this.computeDrawerStyle();
|
||||
this.chatStateManager.didToggleDrawer();
|
||||
this.appEvents.trigger(
|
||||
"chat:toggle-expand",
|
||||
this.chatStateManager.isDrawerExpanded
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.computeDrawerStyle();
|
||||
this.chatStateManager.didCloseDrawer();
|
||||
this.chat.activeChannel = null;
|
||||
}
|
||||
|
||||
@action
|
||||
didResize(element, { width, height }) {
|
||||
this.chatDrawerSize.size = { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
{{#if this.chatStateManager.isDrawerActive}}
|
||||
{{bodyClass "chat-drawer-active"}}
|
||||
{{/if}}
|
||||
|
@ -1,225 +0,0 @@
|
||||
import Component from "@ember/component";
|
||||
import { action } from "@ember/object";
|
||||
import { cancel, next, throttle } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { tagName } from "@ember-decorators/component";
|
||||
import { observes } from "@ember-decorators/object";
|
||||
import { bind } from "discourse/lib/decorators";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import DiscourseURL from "discourse/lib/url";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
|
||||
@tagName("")
|
||||
export default class ChatDrawer extends Component {
|
||||
@service chat;
|
||||
@service router;
|
||||
@service chatDrawerSize;
|
||||
@service chatChannelsManager;
|
||||
@service chatStateManager;
|
||||
@service chatDrawerRouter;
|
||||
|
||||
loading = false;
|
||||
sizeTimer = null;
|
||||
rafTimer = null;
|
||||
hasUnreadMessages = false;
|
||||
drawerStyle = null;
|
||||
|
||||
didInsertElement() {
|
||||
super.didInsertElement(...arguments);
|
||||
|
||||
if (!this.chat.userCanChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._checkSize();
|
||||
this.appEvents.on("chat:open-url", this, "openURL");
|
||||
this.appEvents.on("chat:toggle-close", this, "close");
|
||||
this.appEvents.on("composer:closed", this, "_checkSize");
|
||||
this.appEvents.on("composer:opened", this, "_checkSize");
|
||||
this.appEvents.on("composer:resized", this, "_checkSize");
|
||||
this.appEvents.on("composer:div-resizing", this, "_dynamicCheckSize");
|
||||
window.addEventListener("resize", this._checkSize);
|
||||
this.appEvents.on(
|
||||
"composer:resize-started",
|
||||
this,
|
||||
"_startDynamicCheckSize"
|
||||
);
|
||||
this.appEvents.on("composer:resize-ended", this, "_clearDynamicCheckSize");
|
||||
|
||||
this.computeDrawerStyle();
|
||||
}
|
||||
|
||||
willDestroyElement() {
|
||||
super.willDestroyElement(...arguments);
|
||||
|
||||
if (!this.chat.userCanChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener("resize", this._checkSize);
|
||||
|
||||
if (this.appEvents) {
|
||||
this.appEvents.off("chat:open-url", this, "openURL");
|
||||
this.appEvents.off("chat:toggle-close", this, "close");
|
||||
this.appEvents.off("composer:closed", this, "_checkSize");
|
||||
this.appEvents.off("composer:opened", this, "_checkSize");
|
||||
this.appEvents.off("composer:resized", this, "_checkSize");
|
||||
this.appEvents.off("composer:div-resizing", this, "_dynamicCheckSize");
|
||||
this.appEvents.off(
|
||||
"composer:resize-started",
|
||||
this,
|
||||
"_startDynamicCheckSize"
|
||||
);
|
||||
this.appEvents.off(
|
||||
"composer:resize-ended",
|
||||
this,
|
||||
"_clearDynamicCheckSize"
|
||||
);
|
||||
}
|
||||
if (this.sizeTimer) {
|
||||
cancel(this.sizeTimer);
|
||||
this.sizeTimer = null;
|
||||
}
|
||||
if (this.rafTimer) {
|
||||
window.cancelAnimationFrame(this.rafTimer);
|
||||
}
|
||||
}
|
||||
|
||||
@observes("chatStateManager.isDrawerActive")
|
||||
_fireHiddenAppEvents() {
|
||||
this.appEvents.trigger("chat:rerender-header");
|
||||
}
|
||||
|
||||
computeDrawerStyle() {
|
||||
const { width, height } = this.chatDrawerSize.size;
|
||||
let style = `width: ${escapeExpression((width || "0").toString())}px;`;
|
||||
style += `height: ${escapeExpression((height || "0").toString())}px;`;
|
||||
this.set("drawerStyle", htmlSafe(style));
|
||||
}
|
||||
|
||||
get drawerActions() {
|
||||
return {
|
||||
openInFullPage: this.openInFullPage,
|
||||
close: this.close,
|
||||
toggleExpand: this.toggleExpand,
|
||||
};
|
||||
}
|
||||
|
||||
@bind
|
||||
_dynamicCheckSize() {
|
||||
if (!this.chatStateManager.isDrawerActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.rafTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rafTimer = window.requestAnimationFrame(() => {
|
||||
this.rafTimer = null;
|
||||
this._performCheckSize();
|
||||
});
|
||||
}
|
||||
|
||||
_startDynamicCheckSize() {
|
||||
if (!this.chatStateManager.isDrawerActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector(".chat-drawer-outlet-container")
|
||||
.classList.add("clear-transitions");
|
||||
}
|
||||
|
||||
_clearDynamicCheckSize() {
|
||||
if (!this.chatStateManager.isDrawerActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector(".chat-drawer-outlet-container")
|
||||
.classList.remove("clear-transitions");
|
||||
this._checkSize();
|
||||
}
|
||||
|
||||
@bind
|
||||
_checkSize() {
|
||||
this.sizeTimer = throttle(this, this._performCheckSize, 150);
|
||||
}
|
||||
|
||||
_performCheckSize() {
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawerContainer = document.querySelector(
|
||||
".chat-drawer-outlet-container"
|
||||
);
|
||||
if (!drawerContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const composer = document.getElementById("reply-control");
|
||||
const composerIsClosed = composer.classList.contains("closed");
|
||||
const minRightMargin = 15;
|
||||
|
||||
drawerContainer.style.setProperty(
|
||||
"--composer-right",
|
||||
(composerIsClosed
|
||||
? minRightMargin
|
||||
: Math.max(minRightMargin, composer.offsetLeft)) + "px"
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
openURL(url = null) {
|
||||
this.chat.activeChannel = null;
|
||||
this.chatDrawerRouter.stateFor(this._routeFromURL(url));
|
||||
this.chatStateManager.didOpenDrawer(url);
|
||||
}
|
||||
|
||||
_routeFromURL(url) {
|
||||
let route = this.router.recognize(getURL(url || "/"));
|
||||
|
||||
// ember might recognize the index subroute
|
||||
if (route.localName === "index") {
|
||||
route = route.parent;
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
@action
|
||||
async openInFullPage() {
|
||||
this.chatStateManager.storeAppURL();
|
||||
this.chatStateManager.prefersFullPage();
|
||||
this.chat.activeChannel = null;
|
||||
|
||||
await new Promise((resolve) => next(resolve));
|
||||
|
||||
return DiscourseURL.routeTo(this.chatStateManager.lastKnownChatURL);
|
||||
}
|
||||
|
||||
@action
|
||||
toggleExpand() {
|
||||
this.computeDrawerStyle();
|
||||
this.chatStateManager.didToggleDrawer();
|
||||
this.appEvents.trigger(
|
||||
"chat:toggle-expand",
|
||||
this.chatStateManager.isDrawerExpanded
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.computeDrawerStyle();
|
||||
this.chatStateManager.didCloseDrawer();
|
||||
this.chat.activeChannel = null;
|
||||
}
|
||||
|
||||
@action
|
||||
didResize(element, { width, height }) {
|
||||
this.chatDrawerSize.size = { width, height };
|
||||
}
|
||||
}
|
@ -1,3 +1,229 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { modifier } from "ember-modifier";
|
||||
import domFromString from "discourse/lib/dom-from-string";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import lightbox from "../lib/lightbox";
|
||||
|
||||
export default class ChatMessageCollapser extends Component {
|
||||
@service siteSettings;
|
||||
|
||||
lightbox = modifier((element) => {
|
||||
if (this.args.uploads.length > 0) {
|
||||
lightbox(element.querySelectorAll("img.chat-img-upload"));
|
||||
}
|
||||
});
|
||||
|
||||
get hasUploads() {
|
||||
return hasUploads(this.args.uploads);
|
||||
}
|
||||
|
||||
get uploadsHeader() {
|
||||
let name = "";
|
||||
if (this.args.uploads.length === 1) {
|
||||
name = this.args.uploads[0].original_filename;
|
||||
} else {
|
||||
name = i18n("chat.uploaded_files", { count: this.args.uploads.length });
|
||||
}
|
||||
return htmlSafe(
|
||||
`<span class="chat-message-collapser-link-small">${escapeExpression(
|
||||
name
|
||||
)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
get cookedBodies() {
|
||||
const elements = Array.prototype.slice.call(
|
||||
domFromString(this.args.cooked)
|
||||
);
|
||||
|
||||
if (hasLazyVideo(elements)) {
|
||||
return this.lazyVideoCooked(elements);
|
||||
}
|
||||
|
||||
if (hasImageOnebox(elements)) {
|
||||
return this.imageOneboxCooked(elements);
|
||||
}
|
||||
|
||||
if (hasImage(elements)) {
|
||||
return this.imageCooked(elements);
|
||||
}
|
||||
|
||||
if (hasGallery(elements)) {
|
||||
return this.galleryCooked(elements);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
lazyVideoCooked(elements) {
|
||||
return elements.reduce((acc, e) => {
|
||||
if (this.siteSettings.lazy_videos_enabled && lazyVideoPredicate(e)) {
|
||||
const getVideoAttributes = requirejs(
|
||||
"discourse/plugins/discourse-lazy-videos/lib/lazy-video-attributes"
|
||||
).default;
|
||||
|
||||
const videoAttributes = getVideoAttributes(e);
|
||||
|
||||
if (this.siteSettings[`lazy_${videoAttributes.providerName}_enabled`]) {
|
||||
const link = escapeExpression(videoAttributes.url);
|
||||
const title = videoAttributes.title;
|
||||
const header = htmlSafe(
|
||||
`<a target="_blank" class="chat-message-collapser-link" rel="noopener noreferrer" href="${link}">${title}</a>`
|
||||
);
|
||||
|
||||
acc.push({
|
||||
header,
|
||||
body: e.outerHTML,
|
||||
videoAttributes,
|
||||
needsCollapser: true,
|
||||
});
|
||||
} else {
|
||||
acc.push({ body: e.outerHTML, needsCollapser: false });
|
||||
}
|
||||
} else {
|
||||
acc.push({ body: e.outerHTML, needsCollapser: false });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
imageOneboxCooked(elements) {
|
||||
return elements.reduce((acc, e) => {
|
||||
if (imageOneboxPredicate(e)) {
|
||||
let link = animatedImagePredicate(e)
|
||||
? e.firstChild.src
|
||||
: e.firstElementChild.href;
|
||||
|
||||
link = escapeExpression(link);
|
||||
const header = htmlSafe(
|
||||
`<a target="_blank" class="chat-message-collapser-link-small" rel="noopener noreferrer" href="${link}">${link}</a>`
|
||||
);
|
||||
acc.push({ header, body: e.outerHTML, needsCollapser: true });
|
||||
} else {
|
||||
acc.push({ body: e.outerHTML, needsCollapser: false });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
imageCooked(elements) {
|
||||
return elements.reduce((acc, e) => {
|
||||
if (imagePredicate(e)) {
|
||||
const link = escapeExpression(e.firstElementChild.src);
|
||||
const alt = escapeExpression(e.firstElementChild.alt);
|
||||
const header = htmlSafe(
|
||||
`<a target="_blank" class="chat-message-collapser-link-small" rel="noopener noreferrer" href="${link}">${
|
||||
alt || link
|
||||
}</a>`
|
||||
);
|
||||
acc.push({ header, body: e.outerHTML, needsCollapser: true });
|
||||
} else {
|
||||
acc.push({ body: e.outerHTML, needsCollapser: false });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
galleryCooked(elements) {
|
||||
return elements.reduce((acc, e) => {
|
||||
if (galleryPredicate(e)) {
|
||||
const link = escapeExpression(e.firstElementChild.href);
|
||||
const title = escapeExpression(
|
||||
e.firstElementChild.firstElementChild.textContent
|
||||
);
|
||||
e.firstElementChild.removeChild(e.firstElementChild.firstElementChild);
|
||||
const header = htmlSafe(
|
||||
`<a target="_blank" class="chat-message-collapser-link-small" rel="noopener noreferrer" href="${link}">${title}</a>`
|
||||
);
|
||||
acc.push({ header, body: e.outerHTML, needsCollapser: true });
|
||||
} else {
|
||||
acc.push({ body: e.outerHTML, needsCollapser: false });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
}
|
||||
|
||||
function lazyVideoPredicate(e) {
|
||||
return e.classList.contains("lazy-video-container");
|
||||
}
|
||||
|
||||
function hasLazyVideo(elements) {
|
||||
return elements.some((e) => lazyVideoPredicate(e));
|
||||
}
|
||||
|
||||
function animatedImagePredicate(e) {
|
||||
return (
|
||||
e.firstChild &&
|
||||
e.firstChild.nodeName === "IMG" &&
|
||||
e.firstChild.classList.contains("animated") &&
|
||||
e.firstChild.classList.contains("onebox")
|
||||
);
|
||||
}
|
||||
|
||||
function externalImageOnebox(e) {
|
||||
return (
|
||||
e.firstElementChild &&
|
||||
e.firstElementChild.nodeName === "A" &&
|
||||
e.firstElementChild.classList.contains("onebox") &&
|
||||
e.firstElementChild.firstElementChild &&
|
||||
e.firstElementChild.firstElementChild.nodeName === "IMG"
|
||||
);
|
||||
}
|
||||
|
||||
function imageOneboxPredicate(e) {
|
||||
return animatedImagePredicate(e) || externalImageOnebox(e);
|
||||
}
|
||||
|
||||
function hasImageOnebox(elements) {
|
||||
return elements.some((e) => imageOneboxPredicate(e));
|
||||
}
|
||||
|
||||
function hasUploads(uploads) {
|
||||
return uploads?.length > 0;
|
||||
}
|
||||
|
||||
function imagePredicate(e) {
|
||||
return (
|
||||
e.nodeName === "P" &&
|
||||
e.firstElementChild &&
|
||||
e.firstElementChild.nodeName === "IMG" &&
|
||||
!e.firstElementChild.classList.contains("emoji")
|
||||
);
|
||||
}
|
||||
|
||||
function hasImage(elements) {
|
||||
return elements.some((e) => imagePredicate(e));
|
||||
}
|
||||
|
||||
function galleryPredicate(e) {
|
||||
return (
|
||||
e.firstElementChild &&
|
||||
e.firstElementChild.nodeName === "A" &&
|
||||
e.firstElementChild.firstElementChild &&
|
||||
e.firstElementChild.firstElementChild.classList.contains("outer-box")
|
||||
);
|
||||
}
|
||||
|
||||
function hasGallery(elements) {
|
||||
return elements.some((e) => galleryPredicate(e));
|
||||
}
|
||||
|
||||
export function isCollapsible(cooked, uploads) {
|
||||
const elements = Array.prototype.slice.call(domFromString(cooked));
|
||||
|
||||
return (
|
||||
hasLazyVideo(elements) ||
|
||||
hasImageOnebox(elements) ||
|
||||
hasUploads(uploads) ||
|
||||
hasImage(elements) ||
|
||||
hasGallery(elements)
|
||||
);
|
||||
}
|
||||
|
||||
<div class="chat-message-collapser">
|
||||
{{#if this.hasUploads}}
|
||||
<DecoratedHtml
|
||||
|
@ -1,225 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { modifier } from "ember-modifier";
|
||||
import domFromString from "discourse/lib/dom-from-string";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import lightbox from "../lib/lightbox";
|
||||
|
||||
export default class ChatMessageCollapser extends Component {
|
||||
@service siteSettings;
|
||||
|
||||
lightbox = modifier((element) => {
|
||||
if (this.args.uploads.length > 0) {
|
||||
lightbox(element.querySelectorAll("img.chat-img-upload"));
|
||||
}
|
||||
});
|
||||
|
||||
get hasUploads() {
|
||||
return hasUploads(this.args.uploads);
|
||||
}
|
||||
|
||||
get uploadsHeader() {
|
||||
let name = "";
|
||||
if (this.args.uploads.length === 1) {
|
||||
name = this.args.uploads[0].original_filename;
|
||||
} else {
|
||||
name = i18n("chat.uploaded_files", { count: this.args.uploads.length });
|
||||
}
|
||||
return htmlSafe(
|
||||
`<span class="chat-message-collapser-link-small">${escapeExpression(
|
||||
name
|
||||
)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
get cookedBodies() {
|
||||
const elements = Array.prototype.slice.call(
|
||||
domFromString(this.args.cooked)
|
||||
);
|
||||
|
||||
if (hasLazyVideo(elements)) {
|
||||
return this.lazyVideoCooked(elements);
|
||||
}
|
||||
|
||||
if (hasImageOnebox(elements)) {
|
||||
return this.imageOneboxCooked(elements);
|
||||
}
|
||||
|
||||
if (hasImage(elements)) {
|
||||
return this.imageCooked(elements);
|
||||
}
|
||||
|
||||
if (hasGallery(elements)) {
|
||||
return this.galleryCooked(elements);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
lazyVideoCooked(elements) {
|
||||
return elements.reduce((acc, e) => {
|
||||
if (this.siteSettings.lazy_videos_enabled && lazyVideoPredicate(e)) {
|
||||
const getVideoAttributes = requirejs(
|
||||
"discourse/plugins/discourse-lazy-videos/lib/lazy-video-attributes"
|
||||
).default;
|
||||
|
||||
const videoAttributes = getVideoAttributes(e);
|
||||
|
||||
if (this.siteSettings[`lazy_${videoAttributes.providerName}_enabled`]) {
|
||||
const link = escapeExpression(videoAttributes.url);
|
||||
const title = videoAttributes.title;
|
||||
const header = htmlSafe(
|
||||
`<a target="_blank" class="chat-message-collapser-link" rel="noopener noreferrer" href="${link}">${title}</a>`
|
||||
);
|
||||
|
||||
acc.push({
|
||||
header,
|
||||
body: e.outerHTML,
|
||||
videoAttributes,
|
||||
needsCollapser: true,
|
||||
});
|
||||
} else {
|
||||
acc.push({ body: e.outerHTML, needsCollapser: false });
|
||||
}
|
||||
} else {
|
||||
acc.push({ body: e.outerHTML, needsCollapser: false });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
imageOneboxCooked(elements) {
|
||||
return elements.reduce((acc, e) => {
|
||||
if (imageOneboxPredicate(e)) {
|
||||
let link = animatedImagePredicate(e)
|
||||
? e.firstChild.src
|
||||
: e.firstElementChild.href;
|
||||
|
||||
link = escapeExpression(link);
|
||||
const header = htmlSafe(
|
||||
`<a target="_blank" class="chat-message-collapser-link-small" rel="noopener noreferrer" href="${link}">${link}</a>`
|
||||
);
|
||||
acc.push({ header, body: e.outerHTML, needsCollapser: true });
|
||||
} else {
|
||||
acc.push({ body: e.outerHTML, needsCollapser: false });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
imageCooked(elements) {
|
||||
return elements.reduce((acc, e) => {
|
||||
if (imagePredicate(e)) {
|
||||
const link = escapeExpression(e.firstElementChild.src);
|
||||
const alt = escapeExpression(e.firstElementChild.alt);
|
||||
const header = htmlSafe(
|
||||
`<a target="_blank" class="chat-message-collapser-link-small" rel="noopener noreferrer" href="${link}">${
|
||||
alt || link
|
||||
}</a>`
|
||||
);
|
||||
acc.push({ header, body: e.outerHTML, needsCollapser: true });
|
||||
} else {
|
||||
acc.push({ body: e.outerHTML, needsCollapser: false });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
galleryCooked(elements) {
|
||||
return elements.reduce((acc, e) => {
|
||||
if (galleryPredicate(e)) {
|
||||
const link = escapeExpression(e.firstElementChild.href);
|
||||
const title = escapeExpression(
|
||||
e.firstElementChild.firstElementChild.textContent
|
||||
);
|
||||
e.firstElementChild.removeChild(e.firstElementChild.firstElementChild);
|
||||
const header = htmlSafe(
|
||||
`<a target="_blank" class="chat-message-collapser-link-small" rel="noopener noreferrer" href="${link}">${title}</a>`
|
||||
);
|
||||
acc.push({ header, body: e.outerHTML, needsCollapser: true });
|
||||
} else {
|
||||
acc.push({ body: e.outerHTML, needsCollapser: false });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
}
|
||||
|
||||
function lazyVideoPredicate(e) {
|
||||
return e.classList.contains("lazy-video-container");
|
||||
}
|
||||
|
||||
function hasLazyVideo(elements) {
|
||||
return elements.some((e) => lazyVideoPredicate(e));
|
||||
}
|
||||
|
||||
function animatedImagePredicate(e) {
|
||||
return (
|
||||
e.firstChild &&
|
||||
e.firstChild.nodeName === "IMG" &&
|
||||
e.firstChild.classList.contains("animated") &&
|
||||
e.firstChild.classList.contains("onebox")
|
||||
);
|
||||
}
|
||||
|
||||
function externalImageOnebox(e) {
|
||||
return (
|
||||
e.firstElementChild &&
|
||||
e.firstElementChild.nodeName === "A" &&
|
||||
e.firstElementChild.classList.contains("onebox") &&
|
||||
e.firstElementChild.firstElementChild &&
|
||||
e.firstElementChild.firstElementChild.nodeName === "IMG"
|
||||
);
|
||||
}
|
||||
|
||||
function imageOneboxPredicate(e) {
|
||||
return animatedImagePredicate(e) || externalImageOnebox(e);
|
||||
}
|
||||
|
||||
function hasImageOnebox(elements) {
|
||||
return elements.some((e) => imageOneboxPredicate(e));
|
||||
}
|
||||
|
||||
function hasUploads(uploads) {
|
||||
return uploads?.length > 0;
|
||||
}
|
||||
|
||||
function imagePredicate(e) {
|
||||
return (
|
||||
e.nodeName === "P" &&
|
||||
e.firstElementChild &&
|
||||
e.firstElementChild.nodeName === "IMG" &&
|
||||
!e.firstElementChild.classList.contains("emoji")
|
||||
);
|
||||
}
|
||||
|
||||
function hasImage(elements) {
|
||||
return elements.some((e) => imagePredicate(e));
|
||||
}
|
||||
|
||||
function galleryPredicate(e) {
|
||||
return (
|
||||
e.firstElementChild &&
|
||||
e.firstElementChild.nodeName === "A" &&
|
||||
e.firstElementChild.firstElementChild &&
|
||||
e.firstElementChild.firstElementChild.classList.contains("outer-box")
|
||||
);
|
||||
}
|
||||
|
||||
function hasGallery(elements) {
|
||||
return elements.some((e) => galleryPredicate(e));
|
||||
}
|
||||
|
||||
export function isCollapsible(cooked, uploads) {
|
||||
const elements = Array.prototype.slice.call(domFromString(cooked));
|
||||
|
||||
return (
|
||||
hasLazyVideo(elements) ||
|
||||
hasImageOnebox(elements) ||
|
||||
hasUploads(uploads) ||
|
||||
hasImage(elements) ||
|
||||
hasGallery(elements)
|
||||
);
|
||||
}
|
@ -1,3 +1,47 @@
|
||||
import Component from "@ember/component";
|
||||
import { alias, equal } from "@ember/object/computed";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import discourseComputed from "discourse/lib/decorators";
|
||||
|
||||
export const NEW_TOPIC_SELECTION = "new_topic";
|
||||
export const EXISTING_TOPIC_SELECTION = "existing_topic";
|
||||
export const NEW_MESSAGE_SELECTION = "new_message";
|
||||
|
||||
export default class ChatToTopicSelector extends Component {
|
||||
newTopicSelection = NEW_TOPIC_SELECTION;
|
||||
existingTopicSelection = EXISTING_TOPIC_SELECTION;
|
||||
newMessageSelection = NEW_MESSAGE_SELECTION;
|
||||
selection = null;
|
||||
|
||||
@equal("selection", NEW_TOPIC_SELECTION) newTopic;
|
||||
@equal("selection", EXISTING_TOPIC_SELECTION) existingTopic;
|
||||
@equal("selection", NEW_MESSAGE_SELECTION) newMessage;
|
||||
@alias("site.can_create_tag") canAddTags;
|
||||
@alias("site.can_tag_pms") canTagMessages;
|
||||
|
||||
topicTitle = null;
|
||||
categoryId = null;
|
||||
tags = null;
|
||||
selectedTopicId = null;
|
||||
chatMessageIds = null;
|
||||
chatChannelId = null;
|
||||
|
||||
@discourseComputed()
|
||||
newTopicInstruction() {
|
||||
return htmlSafe(this.instructionLabels[NEW_TOPIC_SELECTION]);
|
||||
}
|
||||
|
||||
@discourseComputed()
|
||||
existingTopicInstruction() {
|
||||
return htmlSafe(this.instructionLabels[EXISTING_TOPIC_SELECTION]);
|
||||
}
|
||||
|
||||
@discourseComputed()
|
||||
newMessageInstruction() {
|
||||
return htmlSafe(this.instructionLabels[NEW_MESSAGE_SELECTION]);
|
||||
}
|
||||
}
|
||||
|
||||
<div class="chat-to-topic-selector">
|
||||
<div class="radios">
|
||||
<label class="radio-label" for="move-to-new-topic">
|
||||
|
@ -1,43 +0,0 @@
|
||||
import Component from "@ember/component";
|
||||
import { alias, equal } from "@ember/object/computed";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import discourseComputed from "discourse/lib/decorators";
|
||||
|
||||
export const NEW_TOPIC_SELECTION = "new_topic";
|
||||
export const EXISTING_TOPIC_SELECTION = "existing_topic";
|
||||
export const NEW_MESSAGE_SELECTION = "new_message";
|
||||
|
||||
export default class ChatToTopicSelector extends Component {
|
||||
newTopicSelection = NEW_TOPIC_SELECTION;
|
||||
existingTopicSelection = EXISTING_TOPIC_SELECTION;
|
||||
newMessageSelection = NEW_MESSAGE_SELECTION;
|
||||
selection = null;
|
||||
|
||||
@equal("selection", NEW_TOPIC_SELECTION) newTopic;
|
||||
@equal("selection", EXISTING_TOPIC_SELECTION) existingTopic;
|
||||
@equal("selection", NEW_MESSAGE_SELECTION) newMessage;
|
||||
@alias("site.can_create_tag") canAddTags;
|
||||
@alias("site.can_tag_pms") canTagMessages;
|
||||
|
||||
topicTitle = null;
|
||||
categoryId = null;
|
||||
tags = null;
|
||||
selectedTopicId = null;
|
||||
chatMessageIds = null;
|
||||
chatChannelId = null;
|
||||
|
||||
@discourseComputed()
|
||||
newTopicInstruction() {
|
||||
return htmlSafe(this.instructionLabels[NEW_TOPIC_SELECTION]);
|
||||
}
|
||||
|
||||
@discourseComputed()
|
||||
existingTopicInstruction() {
|
||||
return htmlSafe(this.instructionLabels[EXISTING_TOPIC_SELECTION]);
|
||||
}
|
||||
|
||||
@discourseComputed()
|
||||
newMessageInstruction() {
|
||||
return htmlSafe(this.instructionLabels[NEW_MESSAGE_SELECTION]);
|
||||
}
|
||||
}
|
@ -1,3 +1,260 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { cancel } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { isBlank, isPresent } from "@ember/utils";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { extractError } from "discourse/lib/ajax-error";
|
||||
import discourseDebounce from "discourse/lib/debounce";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
import Category from "discourse/models/category";
|
||||
import I18n, { i18n } from "discourse-i18n";
|
||||
|
||||
const DEFAULT_HINT = htmlSafe(
|
||||
i18n("chat.create_channel.choose_category.default_hint", {
|
||||
link: "/categories",
|
||||
category: "category",
|
||||
})
|
||||
);
|
||||
|
||||
export default class ChatModalCreateChannel extends Component {
|
||||
@service chat;
|
||||
@service dialog;
|
||||
@service chatChannelsManager;
|
||||
@service chatApi;
|
||||
@service router;
|
||||
@service currentUser;
|
||||
@service siteSettings;
|
||||
@service site;
|
||||
|
||||
@tracked flash;
|
||||
@tracked name;
|
||||
@tracked category;
|
||||
@tracked categoryId;
|
||||
@tracked autoGeneratedSlug = "";
|
||||
@tracked categoryPermissionsHint;
|
||||
@tracked autoJoinWarning = "";
|
||||
@tracked loadingPermissionHint = false;
|
||||
|
||||
#generateSlugHandler = null;
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
cancel(this.#generateSlugHandler);
|
||||
}
|
||||
|
||||
get autoJoinAvailable() {
|
||||
return this.siteSettings.max_chat_auto_joined_users > 0;
|
||||
}
|
||||
|
||||
get categorySelected() {
|
||||
return isPresent(this.category);
|
||||
}
|
||||
|
||||
get createDisabled() {
|
||||
return !this.categorySelected || isBlank(this.name);
|
||||
}
|
||||
|
||||
get categoryName() {
|
||||
return this.categorySelected ? escapeExpression(this.category?.name) : null;
|
||||
}
|
||||
|
||||
@action
|
||||
onShow() {
|
||||
this.categoryPermissionsHint = DEFAULT_HINT;
|
||||
}
|
||||
|
||||
@action
|
||||
onCategoryChange(categoryId) {
|
||||
const category = categoryId ? Category.findById(categoryId) : null;
|
||||
this.#updatePermissionsHint(category);
|
||||
|
||||
const name = this.name || category?.name || "";
|
||||
this.categoryId = categoryId;
|
||||
this.category = category;
|
||||
this.name = name;
|
||||
this.#debouncedGenerateSlug(name);
|
||||
}
|
||||
|
||||
@action
|
||||
onNameChange(name) {
|
||||
this.#debouncedGenerateSlug(name);
|
||||
}
|
||||
|
||||
@action
|
||||
onSave(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.createDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
data.auto_join_users = data.auto_join_users === "on";
|
||||
data.slug ??= this.autoGeneratedSlug;
|
||||
data.threading_enabled = data.threading_enabled === "on";
|
||||
|
||||
if (data.auto_join_users) {
|
||||
this.dialog.yesNoConfirm({
|
||||
message: this.autoJoinWarning,
|
||||
didConfirm: () => this.#createChannel(data),
|
||||
});
|
||||
} else {
|
||||
this.#createChannel(data);
|
||||
}
|
||||
}
|
||||
|
||||
async #createChannel(data) {
|
||||
try {
|
||||
const channel = await this.chatApi.createChannel(data);
|
||||
|
||||
this.args.closeModal();
|
||||
this.chatChannelsManager.follow(channel);
|
||||
this.router.transitionTo("chat.channel", ...channel.routeModels);
|
||||
} catch (e) {
|
||||
this.flash = extractError(e);
|
||||
}
|
||||
}
|
||||
|
||||
#buildCategorySlug(category) {
|
||||
const parent = category.parentCategory;
|
||||
|
||||
if (parent) {
|
||||
return `${this.#buildCategorySlug(parent)}/${category.slug}`;
|
||||
} else {
|
||||
return category.slug;
|
||||
}
|
||||
}
|
||||
|
||||
#updateAutoJoinConfirmWarning(category, catPermissions) {
|
||||
const allowedGroups = catPermissions.allowed_groups;
|
||||
let warning;
|
||||
|
||||
if (catPermissions.private) {
|
||||
switch (allowedGroups.length) {
|
||||
case 1:
|
||||
warning = i18n(
|
||||
"chat.create_channel.auto_join_users.warning_1_group",
|
||||
{
|
||||
count: catPermissions.members_count,
|
||||
group: escapeExpression(allowedGroups[0]),
|
||||
}
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
warning = i18n(
|
||||
"chat.create_channel.auto_join_users.warning_2_groups",
|
||||
{
|
||||
count: catPermissions.members_count,
|
||||
group1: escapeExpression(allowedGroups[0]),
|
||||
group2: escapeExpression(allowedGroups[1]),
|
||||
}
|
||||
);
|
||||
break;
|
||||
default:
|
||||
warning = I18n.messageFormat(
|
||||
"chat.create_channel.auto_join_users.warning_multiple_groups_MF",
|
||||
{
|
||||
groupCount: allowedGroups.length - 1,
|
||||
userCount: catPermissions.members_count,
|
||||
groupName: escapeExpression(allowedGroups[0]),
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
warning = i18n(
|
||||
"chat.create_channel.auto_join_users.public_category_warning",
|
||||
{
|
||||
category: escapeExpression(category.name),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.autoJoinWarning = warning;
|
||||
}
|
||||
|
||||
#updatePermissionsHint(category) {
|
||||
if (category) {
|
||||
const fullSlug = this.#buildCategorySlug(category);
|
||||
|
||||
this.loadingPermissionHint = true;
|
||||
|
||||
return this.chatApi
|
||||
.categoryPermissions(category.id)
|
||||
.then((catPermissions) => {
|
||||
this.#updateAutoJoinConfirmWarning(category, catPermissions);
|
||||
const allowedGroups = catPermissions.allowed_groups;
|
||||
const settingLink = `/c/${escapeExpression(fullSlug)}/edit/security`;
|
||||
let hint;
|
||||
|
||||
switch (allowedGroups.length) {
|
||||
case 1:
|
||||
hint = i18n("chat.create_channel.choose_category.hint_1_group", {
|
||||
settingLink,
|
||||
group: escapeExpression(allowedGroups[0]),
|
||||
});
|
||||
break;
|
||||
case 2:
|
||||
hint = i18n("chat.create_channel.choose_category.hint_2_groups", {
|
||||
settingLink,
|
||||
group1: escapeExpression(allowedGroups[0]),
|
||||
group2: escapeExpression(allowedGroups[1]),
|
||||
});
|
||||
break;
|
||||
default:
|
||||
hint = i18n(
|
||||
"chat.create_channel.choose_category.hint_multiple_groups",
|
||||
{
|
||||
settingLink,
|
||||
group: escapeExpression(allowedGroups[0]),
|
||||
count: allowedGroups.length - 1,
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
this.categoryPermissionsHint = htmlSafe(hint);
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingPermissionHint = false;
|
||||
});
|
||||
} else {
|
||||
this.categoryPermissionsHint = DEFAULT_HINT;
|
||||
this.autoJoinWarning = "";
|
||||
}
|
||||
}
|
||||
|
||||
// intentionally not showing AJAX error for this, we will autogenerate
|
||||
// the slug server-side if they leave it blank
|
||||
#generateSlug(name) {
|
||||
return ajax("/slugs.json", { type: "POST", data: { name } }).then(
|
||||
(response) => {
|
||||
this.autoGeneratedSlug = response.slug;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#debouncedGenerateSlug(name) {
|
||||
cancel(this.#generateSlugHandler);
|
||||
this.autoGeneratedSlug = "";
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#generateSlugHandler = discourseDebounce(
|
||||
this,
|
||||
this.#generateSlug,
|
||||
name,
|
||||
300
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
<DModal
|
||||
@closeModal={{@closeModal}}
|
||||
class="chat-modal-create-channel"
|
||||
|
@ -1,256 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { cancel } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { isBlank, isPresent } from "@ember/utils";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { extractError } from "discourse/lib/ajax-error";
|
||||
import discourseDebounce from "discourse/lib/debounce";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
import Category from "discourse/models/category";
|
||||
import I18n, { i18n } from "discourse-i18n";
|
||||
|
||||
const DEFAULT_HINT = htmlSafe(
|
||||
i18n("chat.create_channel.choose_category.default_hint", {
|
||||
link: "/categories",
|
||||
category: "category",
|
||||
})
|
||||
);
|
||||
|
||||
export default class ChatModalCreateChannel extends Component {
|
||||
@service chat;
|
||||
@service dialog;
|
||||
@service chatChannelsManager;
|
||||
@service chatApi;
|
||||
@service router;
|
||||
@service currentUser;
|
||||
@service siteSettings;
|
||||
@service site;
|
||||
|
||||
@tracked flash;
|
||||
@tracked name;
|
||||
@tracked category;
|
||||
@tracked categoryId;
|
||||
@tracked autoGeneratedSlug = "";
|
||||
@tracked categoryPermissionsHint;
|
||||
@tracked autoJoinWarning = "";
|
||||
@tracked loadingPermissionHint = false;
|
||||
|
||||
#generateSlugHandler = null;
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
cancel(this.#generateSlugHandler);
|
||||
}
|
||||
|
||||
get autoJoinAvailable() {
|
||||
return this.siteSettings.max_chat_auto_joined_users > 0;
|
||||
}
|
||||
|
||||
get categorySelected() {
|
||||
return isPresent(this.category);
|
||||
}
|
||||
|
||||
get createDisabled() {
|
||||
return !this.categorySelected || isBlank(this.name);
|
||||
}
|
||||
|
||||
get categoryName() {
|
||||
return this.categorySelected ? escapeExpression(this.category?.name) : null;
|
||||
}
|
||||
|
||||
@action
|
||||
onShow() {
|
||||
this.categoryPermissionsHint = DEFAULT_HINT;
|
||||
}
|
||||
|
||||
@action
|
||||
onCategoryChange(categoryId) {
|
||||
const category = categoryId ? Category.findById(categoryId) : null;
|
||||
this.#updatePermissionsHint(category);
|
||||
|
||||
const name = this.name || category?.name || "";
|
||||
this.categoryId = categoryId;
|
||||
this.category = category;
|
||||
this.name = name;
|
||||
this.#debouncedGenerateSlug(name);
|
||||
}
|
||||
|
||||
@action
|
||||
onNameChange(name) {
|
||||
this.#debouncedGenerateSlug(name);
|
||||
}
|
||||
|
||||
@action
|
||||
onSave(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.createDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
data.auto_join_users = data.auto_join_users === "on";
|
||||
data.slug ??= this.autoGeneratedSlug;
|
||||
data.threading_enabled = data.threading_enabled === "on";
|
||||
|
||||
if (data.auto_join_users) {
|
||||
this.dialog.yesNoConfirm({
|
||||
message: this.autoJoinWarning,
|
||||
didConfirm: () => this.#createChannel(data),
|
||||
});
|
||||
} else {
|
||||
this.#createChannel(data);
|
||||
}
|
||||
}
|
||||
|
||||
async #createChannel(data) {
|
||||
try {
|
||||
const channel = await this.chatApi.createChannel(data);
|
||||
|
||||
this.args.closeModal();
|
||||
this.chatChannelsManager.follow(channel);
|
||||
this.router.transitionTo("chat.channel", ...channel.routeModels);
|
||||
} catch (e) {
|
||||
this.flash = extractError(e);
|
||||
}
|
||||
}
|
||||
|
||||
#buildCategorySlug(category) {
|
||||
const parent = category.parentCategory;
|
||||
|
||||
if (parent) {
|
||||
return `${this.#buildCategorySlug(parent)}/${category.slug}`;
|
||||
} else {
|
||||
return category.slug;
|
||||
}
|
||||
}
|
||||
|
||||
#updateAutoJoinConfirmWarning(category, catPermissions) {
|
||||
const allowedGroups = catPermissions.allowed_groups;
|
||||
let warning;
|
||||
|
||||
if (catPermissions.private) {
|
||||
switch (allowedGroups.length) {
|
||||
case 1:
|
||||
warning = i18n(
|
||||
"chat.create_channel.auto_join_users.warning_1_group",
|
||||
{
|
||||
count: catPermissions.members_count,
|
||||
group: escapeExpression(allowedGroups[0]),
|
||||
}
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
warning = i18n(
|
||||
"chat.create_channel.auto_join_users.warning_2_groups",
|
||||
{
|
||||
count: catPermissions.members_count,
|
||||
group1: escapeExpression(allowedGroups[0]),
|
||||
group2: escapeExpression(allowedGroups[1]),
|
||||
}
|
||||
);
|
||||
break;
|
||||
default:
|
||||
warning = I18n.messageFormat(
|
||||
"chat.create_channel.auto_join_users.warning_multiple_groups_MF",
|
||||
{
|
||||
groupCount: allowedGroups.length - 1,
|
||||
userCount: catPermissions.members_count,
|
||||
groupName: escapeExpression(allowedGroups[0]),
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
warning = i18n(
|
||||
"chat.create_channel.auto_join_users.public_category_warning",
|
||||
{
|
||||
category: escapeExpression(category.name),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.autoJoinWarning = warning;
|
||||
}
|
||||
|
||||
#updatePermissionsHint(category) {
|
||||
if (category) {
|
||||
const fullSlug = this.#buildCategorySlug(category);
|
||||
|
||||
this.loadingPermissionHint = true;
|
||||
|
||||
return this.chatApi
|
||||
.categoryPermissions(category.id)
|
||||
.then((catPermissions) => {
|
||||
this.#updateAutoJoinConfirmWarning(category, catPermissions);
|
||||
const allowedGroups = catPermissions.allowed_groups;
|
||||
const settingLink = `/c/${escapeExpression(fullSlug)}/edit/security`;
|
||||
let hint;
|
||||
|
||||
switch (allowedGroups.length) {
|
||||
case 1:
|
||||
hint = i18n("chat.create_channel.choose_category.hint_1_group", {
|
||||
settingLink,
|
||||
group: escapeExpression(allowedGroups[0]),
|
||||
});
|
||||
break;
|
||||
case 2:
|
||||
hint = i18n("chat.create_channel.choose_category.hint_2_groups", {
|
||||
settingLink,
|
||||
group1: escapeExpression(allowedGroups[0]),
|
||||
group2: escapeExpression(allowedGroups[1]),
|
||||
});
|
||||
break;
|
||||
default:
|
||||
hint = i18n(
|
||||
"chat.create_channel.choose_category.hint_multiple_groups",
|
||||
{
|
||||
settingLink,
|
||||
group: escapeExpression(allowedGroups[0]),
|
||||
count: allowedGroups.length - 1,
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
this.categoryPermissionsHint = htmlSafe(hint);
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingPermissionHint = false;
|
||||
});
|
||||
} else {
|
||||
this.categoryPermissionsHint = DEFAULT_HINT;
|
||||
this.autoJoinWarning = "";
|
||||
}
|
||||
}
|
||||
|
||||
// intentionally not showing AJAX error for this, we will autogenerate
|
||||
// the slug server-side if they leave it blank
|
||||
#generateSlug(name) {
|
||||
return ajax("/slugs.json", { type: "POST", data: { name } }).then(
|
||||
(response) => {
|
||||
this.autoGeneratedSlug = response.slug;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#debouncedGenerateSlug(name) {
|
||||
cancel(this.#generateSlugHandler);
|
||||
this.autoGeneratedSlug = "";
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#generateSlugHandler = discourseDebounce(
|
||||
this,
|
||||
this.#generateSlug,
|
||||
name,
|
||||
300
|
||||
);
|
||||
}
|
||||
}
|
@ -1,3 +1,52 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import { extractError } from "discourse/lib/ajax-error";
|
||||
|
||||
const DESCRIPTION_MAX_LENGTH = 280;
|
||||
|
||||
export default class ChatModalEditChannelDescription extends Component {
|
||||
@service chatApi;
|
||||
|
||||
@tracked editedDescription = this.channel.description || "";
|
||||
@tracked flash;
|
||||
|
||||
get channel() {
|
||||
return this.args.model;
|
||||
}
|
||||
|
||||
get isSaveDisabled() {
|
||||
return (
|
||||
this.channel.description === this.editedDescription ||
|
||||
this.editedDescription?.length > DESCRIPTION_MAX_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
get descriptionMaxLength() {
|
||||
return DESCRIPTION_MAX_LENGTH;
|
||||
}
|
||||
|
||||
@action
|
||||
async onSaveChatChannelDescription() {
|
||||
try {
|
||||
const result = await this.chatApi.updateChannel(this.channel.id, {
|
||||
description: this.editedDescription,
|
||||
});
|
||||
this.channel.description = result.channel.description;
|
||||
this.args.closeModal();
|
||||
} catch (error) {
|
||||
this.flash = extractError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
onChangeChatChannelDescription(description) {
|
||||
this.flash = null;
|
||||
this.editedDescription = description;
|
||||
}
|
||||
}
|
||||
|
||||
<DModal
|
||||
@closeModal={{@closeModal}}
|
||||
class="chat-modal-edit-channel-description"
|
||||
|
@ -1,48 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import { extractError } from "discourse/lib/ajax-error";
|
||||
|
||||
const DESCRIPTION_MAX_LENGTH = 280;
|
||||
|
||||
export default class ChatModalEditChannelDescription extends Component {
|
||||
@service chatApi;
|
||||
|
||||
@tracked editedDescription = this.channel.description || "";
|
||||
@tracked flash;
|
||||
|
||||
get channel() {
|
||||
return this.args.model;
|
||||
}
|
||||
|
||||
get isSaveDisabled() {
|
||||
return (
|
||||
this.channel.description === this.editedDescription ||
|
||||
this.editedDescription?.length > DESCRIPTION_MAX_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
get descriptionMaxLength() {
|
||||
return DESCRIPTION_MAX_LENGTH;
|
||||
}
|
||||
|
||||
@action
|
||||
async onSaveChatChannelDescription() {
|
||||
try {
|
||||
const result = await this.chatApi.updateChannel(this.channel.id, {
|
||||
description: this.editedDescription,
|
||||
});
|
||||
this.channel.description = result.channel.description;
|
||||
this.args.closeModal();
|
||||
} catch (error) {
|
||||
this.flash = extractError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
onChangeChatChannelDescription(description) {
|
||||
this.flash = null;
|
||||
this.editedDescription = description;
|
||||
}
|
||||
}
|
@ -1,3 +1,26 @@
|
||||
import Component from "@ember/component";
|
||||
import { action } from "@ember/object";
|
||||
import { tagName } from "@ember-decorators/component";
|
||||
|
||||
@tagName("")
|
||||
export default class Collapser extends Component {
|
||||
collapsed = false;
|
||||
header = null;
|
||||
onToggle = null;
|
||||
|
||||
@action
|
||||
open() {
|
||||
this.set("collapsed", false);
|
||||
this.onToggle?.(false);
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.set("collapsed", true);
|
||||
this.onToggle?.(true);
|
||||
}
|
||||
}
|
||||
|
||||
<div class="chat-message-collapser-header">
|
||||
{{this.header}}
|
||||
|
||||
|
@ -1,22 +0,0 @@
|
||||
import Component from "@ember/component";
|
||||
import { action } from "@ember/object";
|
||||
import { tagName } from "@ember-decorators/component";
|
||||
|
||||
@tagName("")
|
||||
export default class Collapser extends Component {
|
||||
collapsed = false;
|
||||
header = null;
|
||||
onToggle = null;
|
||||
|
||||
@action
|
||||
open() {
|
||||
this.set("collapsed", false);
|
||||
this.onToggle?.(false);
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.set("collapsed", true);
|
||||
this.onToggle?.(true);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user