mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-21 12:34:44 +08:00
When CMD+K is pressed (or the link toolbar button clicked) with a non-empty selection, the modal now only asks for the URL — the existing selection is wrapped as the link's display content, keeping every inline mark and block context under it. Previously the modal pre-filled a "Link text" field from the selection. In the rich-text editor this serialized the selected slice to markdown and leaked syntax the user never typed: selecting a word inside an H2 produced `## word`, selecting text inside an inline code span produced backticks, selecting inside a list produced `- word`, and so on. The new behavior wraps the selection directly instead of replacing it: - Rich-text editor: adds a `link` mark over the selection range, preserving every underlying mark (bold, italic, code, emoji, mentions, …) and every surrounding block (heading, list, blockquote, …). - Markdown editor: inserts `[` at the selection start and `](url)` at the end, preserving any literal characters in between. Both editors now behave identically for the common "select then link" flow. The link-text field is still shown when: - Inserting a link with no selection (the user needs to provide display text). - Editing an existing link via the link-toolbar (the user may want to change the displayed text). A small `applyLink(url)` method is exposed on the text-manipulation API of each editor, and `toolbarEvent.applyLink` lets the modal pick the right path without knowing which editor is active. The chat composer is wired through the same path. Ref - t/182095
861 lines
26 KiB
Text
Vendored
861 lines
26 KiB
Text
Vendored
import Component from "@glimmer/component";
|
||
import { tracked } from "@glimmer/tracking";
|
||
import { fn } from "@ember/helper";
|
||
import { on } from "@ember/modifier";
|
||
import { action } from "@ember/object";
|
||
import { getOwner } from "@ember/owner";
|
||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
|
||
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
||
import { cancel, next } from "@ember/runloop";
|
||
import { service } from "@ember/service";
|
||
import { isPresent } from "@ember/utils";
|
||
import {
|
||
emojiSearch,
|
||
isSkinTonableEmoji,
|
||
normalizeEmoji,
|
||
} from "pretty-text/emoji";
|
||
import { replacements, translations } from "pretty-text/emoji/data";
|
||
import { Promise } from "rsvp";
|
||
import EmojiAutocompleteResults from "discourse/components/emoji-autocomplete-results";
|
||
import EmojiPickerDetached from "discourse/components/emoji-picker/detached";
|
||
import UpsertHyperlink from "discourse/components/modal/upsert-hyperlink";
|
||
import PluginOutlet from "discourse/components/plugin-outlet";
|
||
import UserAutocompleteResults from "discourse/components/user-autocomplete-results";
|
||
import lazyHash from "discourse/helpers/lazy-hash";
|
||
import { hashtagAutocompleteOptions } from "discourse/lib/hashtag-autocomplete";
|
||
import loadEmojiSearchAliases from "discourse/lib/load-emoji-search-aliases";
|
||
import { cloneJSON } from "discourse/lib/object";
|
||
import optionalService from "discourse/lib/optional-service";
|
||
import { emojiUrlFor } from "discourse/lib/text";
|
||
import { TextareaAutocompleteHandler } from "discourse/lib/textarea-text-manipulation";
|
||
import userSearch, { validateSearchResult } from "discourse/lib/user-search";
|
||
import {
|
||
destroyUserStatuses,
|
||
initUserStatusHtml,
|
||
renderUserStatusHtml,
|
||
} from "discourse/lib/user-status-on-autocomplete";
|
||
import { optionalRequire } from "discourse/lib/utilities";
|
||
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
|
||
import { waitForClosedKeyboard } from "discourse/lib/wait-for-keyboard";
|
||
import forceScrollingElementPosition from "discourse/modifiers/force-scrolling-element-position";
|
||
import preventScrollOnFocus from "discourse/modifiers/prevent-scroll-on-focus";
|
||
import { not, or } from "discourse/truth-helpers";
|
||
import DTextarea from "discourse/ui-kit/d-textarea";
|
||
import dConcatClass from "discourse/ui-kit/helpers/d-concat-class";
|
||
import dAutocomplete, { SKIP } from "discourse/ui-kit/modifiers/d-autocomplete";
|
||
import { i18n } from "discourse-i18n";
|
||
import DButton from "discourse/plugins/chat/discourse/components/chat/composer/button";
|
||
import ChatComposerDropdown from "discourse/plugins/chat/discourse/components/chat-composer-dropdown";
|
||
import ChatComposerMessageDetails from "discourse/plugins/chat/discourse/components/chat-composer-message-details";
|
||
import ChatComposerUploads from "discourse/plugins/chat/discourse/components/chat-composer-uploads";
|
||
import ChatReplyingIndicator from "discourse/plugins/chat/discourse/components/chat-replying-indicator";
|
||
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 site;
|
||
@service siteSettings;
|
||
@service capabilities;
|
||
@service store;
|
||
@service chat;
|
||
@service chatComposerWarningsTracker;
|
||
@service appEvents;
|
||
@service emojiStore;
|
||
@service currentUser;
|
||
@service modal;
|
||
@service menu;
|
||
|
||
@optionalService composerPresenceManager;
|
||
|
||
@tracked isFocused = false;
|
||
@tracked inProgressUploadsCount = 0;
|
||
|
||
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) {
|
||
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 });
|
||
}
|
||
}
|
||
|
||
applyAutocomplete(textarea, options) {
|
||
const autocompleteHandler = new TextareaAutocompleteHandler(textarea);
|
||
return dAutocomplete.setupAutocomplete(
|
||
getOwner(this),
|
||
textarea,
|
||
autocompleteHandler,
|
||
options
|
||
);
|
||
}
|
||
|
||
@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,
|
||
"openUpsertLinkModal"
|
||
);
|
||
}
|
||
|
||
@action
|
||
teardown() {
|
||
this.appEvents.off("chat:modify-selection", this, "modifySelection");
|
||
this.appEvents.off(
|
||
"chat:open-insert-link-modal",
|
||
this,
|
||
"openUpsertLinkModal"
|
||
);
|
||
this.pane.sending = false;
|
||
}
|
||
|
||
@action
|
||
insertDiscourseLocalDate() {
|
||
const LocalDatesCreateModal = optionalRequire(
|
||
"discourse/plugins/discourse-local-dates/discourse/components/modal/local-dates-create"
|
||
);
|
||
|
||
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");
|
||
}
|
||
}
|
||
|
||
forceScrollPosition() {
|
||
if (!this.capabilities.isIOS || this.capabilities.isIpadOS) {
|
||
return;
|
||
}
|
||
|
||
// attempts to reposition body
|
||
if (window.pageYOffset <= 100) {
|
||
// on iOS scrolling to 0 doesn’t work correctly
|
||
// scrolling to -1 is more consistent
|
||
window.scrollTo(0, -1);
|
||
}
|
||
}
|
||
|
||
@action
|
||
onTextareaFocusOut() {
|
||
this.forceScrollPosition();
|
||
this.isFocused = false;
|
||
}
|
||
|
||
@action
|
||
onTextareaFocusIn() {
|
||
this.forceScrollPosition();
|
||
this.isFocused = true;
|
||
}
|
||
|
||
@action
|
||
onKeyDown(event) {
|
||
if (
|
||
this.capabilities.isMobileDevice ||
|
||
event.altKey ||
|
||
event.isComposing ||
|
||
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
|
||
openUpsertLinkModal(event, options = { context: null }) {
|
||
if (options.context !== this.context) {
|
||
return;
|
||
}
|
||
|
||
const selected = this.composer.textarea.getSelected("", { lineVal: true });
|
||
const hasSelection = !!selected && selected.start !== selected.end;
|
||
this.modal.show(UpsertHyperlink, {
|
||
model: {
|
||
hasSelection,
|
||
toolbarEvent: {
|
||
selected,
|
||
addText: (text) => this.composer.textarea.addText(selected, text),
|
||
applyLink: (url) => this.composer.textarea.applyLink(url),
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
@action
|
||
onSelectEmoji(emoji, context = {}) {
|
||
const textareaInteractor = this.composer.textarea;
|
||
|
||
if (context.emojiTermStart && context.emojiTermStart) {
|
||
const value = textareaInteractor.textarea.value;
|
||
const valueUpToCursor = `${value.substring(0, context.emojiTermStart)}:${emoji}: `;
|
||
const valueAfterCursor = value.substring(context.emojiTermEnd + 1);
|
||
textareaInteractor.value = `${valueUpToCursor}${valueAfterCursor}`;
|
||
textareaInteractor.textarea.setSelectionRange(
|
||
valueUpToCursor.length,
|
||
valueUpToCursor.length
|
||
);
|
||
} else {
|
||
textareaInteractor.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;
|
||
}
|
||
|
||
this.applyAutocomplete(textarea, {
|
||
component: UserAutocompleteResults,
|
||
key: UserAutocompleteResults.TRIGGER_KEY,
|
||
width: "100%",
|
||
treatAsTextarea: true,
|
||
fixedTextareaPosition: true,
|
||
autoSelectFirstSuggestion: true,
|
||
transformComplete: (obj) => {
|
||
if (obj.isUser) {
|
||
this.#addMentionedUser(cloneJSON(obj));
|
||
}
|
||
validateSearchResult(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?.map(
|
||
(item) => item.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) {
|
||
this.applyAutocomplete(
|
||
textarea,
|
||
hashtagAutocompleteOptions(
|
||
this.site.hashtag_configurations["chat-composer"],
|
||
{
|
||
fixedTextareaPosition: true,
|
||
treatAsTextarea: true,
|
||
afterComplete: (text, event) => {
|
||
event.preventDefault();
|
||
this.composer.textarea.value = text;
|
||
this.composer.focus();
|
||
},
|
||
}
|
||
)
|
||
);
|
||
}
|
||
|
||
#applyEmojiAutocomplete(textarea) {
|
||
if (!this.siteSettings.enable_emoji) {
|
||
return;
|
||
}
|
||
|
||
this.applyAutocomplete(textarea, {
|
||
component: EmojiAutocompleteResults,
|
||
key: EmojiAutocompleteResults.TRIGGER_KEY,
|
||
afterComplete: (text, event) => {
|
||
event.preventDefault();
|
||
this.composer.textarea.value = text;
|
||
this.composer.focus();
|
||
},
|
||
treatAsTextarea: true,
|
||
fixedTextareaPosition: 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 {
|
||
// Capture emoji term positioning before opening the emoji picker which resets the textarea state
|
||
const textareaInteractor = this.composer.textarea;
|
||
const currentValue = textareaInteractor.textarea.value;
|
||
const currentCaretPos = textareaInteractor.textarea.selectionStart;
|
||
|
||
let emojiContext = null;
|
||
|
||
if (currentValue && currentCaretPos !== undefined) {
|
||
const textBeforeCursor = currentValue.substring(0, currentCaretPos);
|
||
const incompleteMatch = textBeforeCursor.match(/(:[\w-]+)$/);
|
||
|
||
if (incompleteMatch) {
|
||
emojiContext = {
|
||
emojiTermStart: currentCaretPos - incompleteMatch[1].length,
|
||
emojiTermEnd: currentCaretPos - 1,
|
||
};
|
||
}
|
||
}
|
||
|
||
const menuOptions = {
|
||
identifier: "emoji-picker",
|
||
groupIdentifier: "emoji-picker",
|
||
component: EmojiPickerDetached,
|
||
context: "chat",
|
||
modalForMobile: true,
|
||
data: {
|
||
didSelectEmoji: (emoji) => {
|
||
this.onSelectEmoji(emoji, emojiContext);
|
||
},
|
||
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.site, this.capabilities);
|
||
|
||
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>
|
||
{{! eslint-disable ember/template-no-pointer-down-event-binding }}
|
||
{{! eslint-disable ember/template-no-invalid-interactive }}
|
||
|
||
<div class="chat-composer__wrapper">
|
||
{{#if this.shouldRenderMessageDetails}}
|
||
<ChatComposerMessageDetails
|
||
@message={{if this.draft.editing this.draft this.draft.inReplyTo}}
|
||
@cancelAction={{this.resetDraft}}
|
||
/>
|
||
{{/if}}
|
||
|
||
<div
|
||
role="region"
|
||
aria-label={{i18n "chat.aria_roles.composer"}}
|
||
class={{dConcatClass
|
||
"chat-composer"
|
||
(if this.isFocused "is-focused")
|
||
(if this.pane.sending "is-sending")
|
||
(if this.sendEnabled "is-send-enabled" "is-send-disabled")
|
||
(if this.disabled "is-disabled" "is-enabled")
|
||
(if this.draft.draftSaved "is-draft-saved" "is-draft-unsaved")
|
||
}}
|
||
{{didUpdate this.didUpdateMessage this.draft}}
|
||
{{didUpdate this.didUpdateInReplyTo this.draft.inReplyTo}}
|
||
{{didInsert this.setup}}
|
||
{{willDestroy this.teardown}}
|
||
{{willDestroy this.cancelPersistDraft}}
|
||
>
|
||
<div class="chat-composer__outer-container">
|
||
{{#if this.site.mobileView}}
|
||
<ChatComposerDropdown
|
||
@buttons={{this.dropdownButtons}}
|
||
@isDisabled={{this.disabled}}
|
||
/>
|
||
{{/if}}
|
||
|
||
<div class="chat-composer__inner-container">
|
||
{{#if this.site.desktopView}}
|
||
<ChatComposerDropdown
|
||
@buttons={{this.dropdownButtons}}
|
||
@isDisabled={{this.disabled}}
|
||
/>
|
||
{{/if}}
|
||
|
||
<div
|
||
class="chat-composer__input-container"
|
||
{{on "click" this.composer.focus}}
|
||
>
|
||
<DTextarea
|
||
{{preventScrollOnFocus}}
|
||
{{forceScrollingElementPosition}}
|
||
id={{this.composerId}}
|
||
value={{readonly this.draft.message}}
|
||
type="text"
|
||
class="chat-composer__input"
|
||
disabled={{this.disabled}}
|
||
autocorrect="on"
|
||
autocapitalize="sentences"
|
||
placeholder={{this.placeholder}}
|
||
rows={{1}}
|
||
{{didInsert this.setupTextareaInteractor}}
|
||
{{on "input" this.onInput}}
|
||
{{on "keydown" this.onKeyDown}}
|
||
{{on "focusin" this.onTextareaFocusIn}}
|
||
{{on "focusout" this.onTextareaFocusOut}}
|
||
{{didInsert this.setupAutocomplete}}
|
||
data-chat-composer-context={{this.context}}
|
||
/>
|
||
</div>
|
||
|
||
{{#each this.inlineButtons as |button|}}
|
||
<DButton
|
||
@icon={{button.icon}}
|
||
class="-{{button.id}}"
|
||
disabled={{or this.disabled button.disabled}}
|
||
tabindex={{if button.disabled -1 0}}
|
||
{{on "click" (fn this.handleInlineButtonAction button.action)}}
|
||
{{on "focus" (fn this.computeIsFocused true)}}
|
||
{{on "blur" (fn this.computeIsFocused false)}}
|
||
/>
|
||
{{/each}}
|
||
|
||
<PluginOutlet
|
||
@name="chat-composer-inline-buttons"
|
||
@outletArgs={{lazyHash composer=this channel=@channel}}
|
||
/>
|
||
|
||
{{#if this.site.desktopView}}
|
||
<DButton
|
||
@icon="paper-plane"
|
||
class="-send"
|
||
title={{i18n "chat.composer.send"}}
|
||
disabled={{or this.disabled (not this.sendEnabled)}}
|
||
tabindex={{if this.sendEnabled 0 -1}}
|
||
{{on "click" this.onSend}}
|
||
{{on "mousedown" this.trapMouseDown}}
|
||
{{on "focus" (fn this.computeIsFocused true)}}
|
||
{{on "blur" (fn this.computeIsFocused false)}}
|
||
/>
|
||
{{/if}}
|
||
</div>
|
||
{{#if this.site.mobileView}}
|
||
<DButton
|
||
@icon="paper-plane"
|
||
class="-send"
|
||
title={{i18n "chat.composer.send"}}
|
||
disabled={{or this.disabled (not this.sendEnabled)}}
|
||
tabindex={{if this.sendEnabled 0 -1}}
|
||
{{on "click" this.onSend}}
|
||
{{on "mousedown" this.trapMouseDown}}
|
||
{{on "focus" (fn this.computeIsFocused true)}}
|
||
{{on "blur" (fn this.computeIsFocused false)}}
|
||
/>
|
||
{{/if}}
|
||
</div>
|
||
</div>
|
||
|
||
{{#if this.canAttachUploads}}
|
||
<ChatComposerUploads
|
||
@fileUploadElementId={{this.fileUploadElementId}}
|
||
@onUploadChanged={{this.onUploadChanged}}
|
||
@existingUploads={{this.draft.uploads}}
|
||
@uploadDropZone={{@uploadDropZone}}
|
||
@composerInputEl={{this.composer.textarea.element}}
|
||
/>
|
||
{{/if}}
|
||
|
||
<div class="chat-replying-indicator-container">
|
||
<ChatReplyingIndicator
|
||
@presenceChannelName={{this.presenceChannelName}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
}
|