discourse/plugins/chat/assets/javascripts/discourse/components/chat-composer.gjs
Régis Hanol 7bb363b4cb
FIX: preserve selection formatting in link-insertion modal (#39374)
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
2026-05-20 11:22:35 +02:00

861 lines
26 KiB
Text
Vendored
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 doesnt 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>
}