UX: adds chat send shortcut user preference (#30473)
Users can now decide if they want to send a message on: - <kbd>enter</kbd> - <kbd>meta + enter</kbd> If you choose <kbd>meta + enter</kbd>, <kbd>enter</kbd> will add a linebreak. <img width="192" alt="Screenshot 2025-01-21 at 12 57 48" src="https://github.com/user-attachments/assets/abfd6f8b-83b3-4e6f-be67-8f63d536ca8a" />
This commit is contained in:
parent
a66e5ff728
commit
2cff8c82e3
|
@ -611,6 +611,12 @@ textarea {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special elements
|
// Special elements
|
||||||
|
|
|
@ -296,6 +296,7 @@ end
|
||||||
# sidebar_show_count_of_new_items :boolean default(FALSE), not null
|
# sidebar_show_count_of_new_items :boolean default(FALSE), not null
|
||||||
# watched_precedence_over_muted :boolean
|
# watched_precedence_over_muted :boolean
|
||||||
# chat_separate_sidebar_mode :integer default(0), not null
|
# chat_separate_sidebar_mode :integer default(0), not null
|
||||||
|
# chat_send_shortcut :integer default(0), not null
|
||||||
# topics_unread_when_closed :boolean default(TRUE), not null
|
# topics_unread_when_closed :boolean default(TRUE), not null
|
||||||
# show_thread_title_prompts :boolean default(TRUE), not null
|
# show_thread_title_prompts :boolean default(TRUE), not null
|
||||||
# enable_smart_lists :boolean default(TRUE), not null
|
# enable_smart_lists :boolean default(TRUE), not null
|
||||||
|
|
|
@ -309,7 +309,6 @@ export default class ChatComposer extends Component {
|
||||||
if (
|
if (
|
||||||
this.site.mobileView ||
|
this.site.mobileView ||
|
||||||
event.altKey ||
|
event.altKey ||
|
||||||
event.metaKey ||
|
|
||||||
this.#isAutocompleteDisplayed()
|
this.#isAutocompleteDisplayed()
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
|
@ -320,18 +319,22 @@ export default class ChatComposer extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
if (event.shiftKey) {
|
// if we are inside a code block just insert newline
|
||||||
// Shift+Enter: insert newline
|
const { pre } = this.composer.textarea.getSelected({ lineVal: true });
|
||||||
|
if (this.composer.textarea.isInside(pre, /(^|\n)```/g)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+Enter, plain Enter: send
|
const shortcutPreference =
|
||||||
if (!event.ctrlKey) {
|
this.currentUser.user_option.chat_send_shortcut;
|
||||||
// if we are inside a code block just insert newline
|
const send =
|
||||||
const { pre } = this.composer.textarea.getSelected({ lineVal: true });
|
(shortcutPreference === "enter" && !event.shiftKey) ||
|
||||||
if (this.composer.textarea.isInside(pre, /(^|\n)```/g)) {
|
event.ctrlKey ||
|
||||||
return;
|
event.metaKey;
|
||||||
}
|
|
||||||
|
if (!send) {
|
||||||
|
// insert newline
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.onSend();
|
this.onSend();
|
||||||
|
|
|
@ -4,6 +4,8 @@ import { service } from "@ember/service";
|
||||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
import discourseComputed from "discourse/lib/decorators";
|
import discourseComputed from "discourse/lib/decorators";
|
||||||
import { isTesting } from "discourse/lib/environment";
|
import { isTesting } from "discourse/lib/environment";
|
||||||
|
import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts";
|
||||||
|
import { translateModKey } from "discourse/lib/utilities";
|
||||||
import { i18n } from "discourse-i18n";
|
import { i18n } from "discourse-i18n";
|
||||||
import { CHAT_SOUNDS } from "discourse/plugins/chat/discourse/services/chat-audio-manager";
|
import { CHAT_SOUNDS } from "discourse/plugins/chat/discourse/services/chat-audio-manager";
|
||||||
|
|
||||||
|
@ -16,6 +18,7 @@ const CHAT_ATTRS = [
|
||||||
"chat_email_frequency",
|
"chat_email_frequency",
|
||||||
"chat_header_indicator_preference",
|
"chat_header_indicator_preference",
|
||||||
"chat_separate_sidebar_mode",
|
"chat_separate_sidebar_mode",
|
||||||
|
"chat_send_shortcut",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const HEADER_INDICATOR_PREFERENCE_NEVER = "never";
|
export const HEADER_INDICATOR_PREFERENCE_NEVER = "never";
|
||||||
|
@ -29,6 +32,21 @@ export default class PreferencesChatController extends Controller {
|
||||||
|
|
||||||
subpageTitle = i18n("chat.admin.title");
|
subpageTitle = i18n("chat.admin.title");
|
||||||
|
|
||||||
|
chatSendShortcutOptions = [
|
||||||
|
{
|
||||||
|
label: i18n("chat.send_shortcut.enter.label"),
|
||||||
|
value: "enter",
|
||||||
|
description: i18n("chat.send_shortcut.enter.description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n("chat.send_shortcut.meta_enter.label", {
|
||||||
|
meta_key: translateModKey(PLATFORM_KEY_MODIFIER),
|
||||||
|
}),
|
||||||
|
value: "meta_enter",
|
||||||
|
description: i18n("chat.send_shortcut.meta_enter.description"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
emailFrequencyOptions = [
|
emailFrequencyOptions = [
|
||||||
{ name: i18n("chat.email_frequency.never"), value: "never" },
|
{ name: i18n("chat.email_frequency.never"), value: "never" },
|
||||||
{ name: i18n("chat.email_frequency.when_away"), value: "when_away" },
|
{ name: i18n("chat.email_frequency.when_away"), value: "when_away" },
|
||||||
|
@ -77,6 +95,10 @@ export default class PreferencesChatController extends Controller {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get chatSendShortcut() {
|
||||||
|
return this.model.get("user_option.chat_send_shortcut");
|
||||||
|
}
|
||||||
|
|
||||||
@discourseComputed
|
@discourseComputed
|
||||||
chatSounds() {
|
chatSounds() {
|
||||||
return Object.keys(CHAT_SOUNDS).map((value) => {
|
return Object.keys(CHAT_SOUNDS).map((value) => {
|
||||||
|
|
|
@ -8,6 +8,7 @@ const CHAT_SOUND = "chat_sound";
|
||||||
const CHAT_EMAIL_FREQUENCY = "chat_email_frequency";
|
const CHAT_EMAIL_FREQUENCY = "chat_email_frequency";
|
||||||
const CHAT_HEADER_INDICATOR_PREFERENCE = "chat_header_indicator_preference";
|
const CHAT_HEADER_INDICATOR_PREFERENCE = "chat_header_indicator_preference";
|
||||||
const CHAT_SEPARATE_SIDEBAR_MODE = "chat_separate_sidebar_mode";
|
const CHAT_SEPARATE_SIDEBAR_MODE = "chat_separate_sidebar_mode";
|
||||||
|
const CHAT_SEND_SHORTCUT = "chat_send_shortcut";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "chat-user-options",
|
name: "chat-user-options",
|
||||||
|
@ -24,6 +25,7 @@ export default {
|
||||||
api.addSaveableUserOptionField(CHAT_EMAIL_FREQUENCY);
|
api.addSaveableUserOptionField(CHAT_EMAIL_FREQUENCY);
|
||||||
api.addSaveableUserOptionField(CHAT_HEADER_INDICATOR_PREFERENCE);
|
api.addSaveableUserOptionField(CHAT_HEADER_INDICATOR_PREFERENCE);
|
||||||
api.addSaveableUserOptionField(CHAT_SEPARATE_SIDEBAR_MODE);
|
api.addSaveableUserOptionField(CHAT_SEPARATE_SIDEBAR_MODE);
|
||||||
|
api.addSaveableUserOptionField(CHAT_SEND_SHORTCUT);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -116,6 +116,40 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="control-group chat-setting controls-dropdown"
|
||||||
|
data-setting-name="user_chat_send_shortcut"
|
||||||
|
>
|
||||||
|
<div class="radio-group">
|
||||||
|
{{#each this.chatSendShortcutOptions as |option|}}
|
||||||
|
<div class="radio-group-option">
|
||||||
|
<label class="controls">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="chat_send_shortcut"
|
||||||
|
id={{concat "chat_send_shortcut_" option.value}}
|
||||||
|
value={{option.value}}
|
||||||
|
checked={{eq
|
||||||
|
this.model.user_option.chat_send_shortcut
|
||||||
|
option.value
|
||||||
|
}}
|
||||||
|
{{on
|
||||||
|
"change"
|
||||||
|
(withEventValue
|
||||||
|
(fn (mut this.model.user_option.chat_send_shortcut))
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{{option.label}}
|
||||||
|
</label>
|
||||||
|
<span class="control-instructions">
|
||||||
|
{{option.description}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SaveControls
|
<SaveControls
|
||||||
@id="user_chat_preference_save"
|
@id="user_chat_preference_save"
|
||||||
@model={{this.model}}
|
@model={{this.model}}
|
||||||
|
|
|
@ -136,6 +136,13 @@ en:
|
||||||
never: "Never"
|
never: "Never"
|
||||||
separate_sidebar_mode:
|
separate_sidebar_mode:
|
||||||
title: "Show separate sidebar modes for forum and chat"
|
title: "Show separate sidebar modes for forum and chat"
|
||||||
|
send_shortcut:
|
||||||
|
enter:
|
||||||
|
label: "Send by Enter"
|
||||||
|
description: "New line by Shift + Enter"
|
||||||
|
meta_enter:
|
||||||
|
label: "Send by %{meta_key} + Enter"
|
||||||
|
description: "New line by Enter"
|
||||||
enable: "Enable chat"
|
enable: "Enable chat"
|
||||||
flag: "Flag"
|
flag: "Flag"
|
||||||
emoji: "Insert emoji"
|
emoji: "Insert emoji"
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddChatSendShortcutPreference < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :user_options, :chat_send_shortcut, :integer, default: 0, null: false
|
||||||
|
end
|
||||||
|
end
|
|
@ -39,6 +39,10 @@ module Chat
|
||||||
@chat_separate_sidebar_mode ||= { default: 0, never: 1, always: 2, fullscreen: 3 }
|
@chat_separate_sidebar_mode ||= { default: 0, never: 1, always: 2, fullscreen: 3 }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def base.chat_send_shortcut
|
||||||
|
@chat_send_shortcut ||= { enter: 0, meta_enter: 1 }
|
||||||
|
end
|
||||||
|
|
||||||
# Avoid attempting to override when autoloading
|
# Avoid attempting to override when autoloading
|
||||||
if !base.method_defined?(:chat_separate_sidebar_mode_default?)
|
if !base.method_defined?(:chat_separate_sidebar_mode_default?)
|
||||||
base.enum :chat_separate_sidebar_mode,
|
base.enum :chat_separate_sidebar_mode,
|
||||||
|
@ -46,6 +50,10 @@ module Chat
|
||||||
prefix: "chat_separate_sidebar_mode"
|
prefix: "chat_separate_sidebar_mode"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if !base.method_defined?(:chat_send_shortcut_default?)
|
||||||
|
base.enum :chat_send_shortcut, base.chat_send_shortcut, prefix: "chat_send_shortcut"
|
||||||
|
end
|
||||||
|
|
||||||
if !base.method_defined?(:show_thread_title_prompts?)
|
if !base.method_defined?(:show_thread_title_prompts?)
|
||||||
base.attribute :show_thread_title_prompts, :boolean, default: true
|
base.attribute :show_thread_title_prompts, :boolean, default: true
|
||||||
end
|
end
|
||||||
|
|
|
@ -61,6 +61,7 @@ after_initialize do
|
||||||
UserUpdater::OPTION_ATTR.push(:chat_email_frequency)
|
UserUpdater::OPTION_ATTR.push(:chat_email_frequency)
|
||||||
UserUpdater::OPTION_ATTR.push(:chat_header_indicator_preference)
|
UserUpdater::OPTION_ATTR.push(:chat_header_indicator_preference)
|
||||||
UserUpdater::OPTION_ATTR.push(:chat_separate_sidebar_mode)
|
UserUpdater::OPTION_ATTR.push(:chat_separate_sidebar_mode)
|
||||||
|
UserUpdater::OPTION_ATTR.push(:chat_send_shortcut)
|
||||||
|
|
||||||
register_reviewable_type Chat::ReviewableMessage
|
register_reviewable_type Chat::ReviewableMessage
|
||||||
|
|
||||||
|
@ -246,6 +247,10 @@ after_initialize do
|
||||||
object.chat_separate_sidebar_mode
|
object.chat_separate_sidebar_mode
|
||||||
end
|
end
|
||||||
|
|
||||||
|
add_to_serializer(:user_option, :chat_send_shortcut) { object.chat_send_shortcut }
|
||||||
|
|
||||||
|
add_to_serializer(:current_user_option, :chat_send_shortcut) { object.chat_send_shortcut }
|
||||||
|
|
||||||
on(:site_setting_changed) do |name, old_value, new_value|
|
on(:site_setting_changed) do |name, old_value, new_value|
|
||||||
user_option_field = Chat::RETENTION_SETTINGS_TO_USER_OPTION_FIELDS[name.to_sym]
|
user_option_field = Chat::RETENTION_SETTINGS_TO_USER_OPTION_FIELDS[name.to_sym]
|
||||||
begin
|
begin
|
||||||
|
|
|
@ -104,6 +104,74 @@ RSpec.describe "Chat composer", type: :system do
|
||||||
|
|
||||||
expect(channel_page.composer.value).to eq("bb")
|
expect(channel_page.composer.value).to eq("bb")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context "when user preference is set to send on enter" do
|
||||||
|
before { current_user.user_option.update!(chat_send_shortcut: 0) }
|
||||||
|
|
||||||
|
context "when pressing enter" do
|
||||||
|
it "sends the message" do
|
||||||
|
chat_page.visit_channel(channel_1)
|
||||||
|
|
||||||
|
channel_page.composer.fill_in(with: "testenter").enter_shortcut
|
||||||
|
|
||||||
|
expect(channel_page.messages).to have_message(text: "testenter")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when pressing shift + enter" do
|
||||||
|
it "adds a linebreak" do
|
||||||
|
chat_page.visit_channel(channel_1)
|
||||||
|
|
||||||
|
channel_page.composer.fill_in(with: "testenter").shift_enter_shortcut
|
||||||
|
|
||||||
|
expect(channel_page.composer.value).to eq("testenter\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when pressing meta + enter" do
|
||||||
|
it "sends the message" do
|
||||||
|
chat_page.visit_channel(channel_1)
|
||||||
|
|
||||||
|
channel_page.composer.fill_in(with: "testenter").meta_enter_shortcut
|
||||||
|
|
||||||
|
expect(channel_page.messages).to have_message(text: "testenter")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when user preference is set to send on meta + enter" do
|
||||||
|
before { current_user.user_option.update!(chat_send_shortcut: 1) }
|
||||||
|
|
||||||
|
context "when pressing enter" do
|
||||||
|
it "adds a linebreak" do
|
||||||
|
chat_page.visit_channel(channel_1)
|
||||||
|
|
||||||
|
channel_page.composer.fill_in(with: "testenter").enter_shortcut
|
||||||
|
|
||||||
|
expect(channel_page.composer.value).to eq("testenter\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when pressing shift + enter" do
|
||||||
|
it "adds a linebreak" do
|
||||||
|
chat_page.visit_channel(channel_1)
|
||||||
|
|
||||||
|
channel_page.composer.fill_in(with: "testenter").shift_enter_shortcut
|
||||||
|
|
||||||
|
expect(channel_page.composer.value).to eq("testenter\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when pressing meta + enter" do
|
||||||
|
it "sends the message" do
|
||||||
|
chat_page.visit_channel(channel_1)
|
||||||
|
|
||||||
|
channel_page.composer.fill_in(with: "testenter").meta_enter_shortcut
|
||||||
|
|
||||||
|
expect(channel_page.messages).to have_message(text: "testenter")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when editing a message with no length" do
|
context "when editing a message with no length" do
|
||||||
|
|
|
@ -42,6 +42,7 @@ module PageObjects
|
||||||
|
|
||||||
def fill_in(**args)
|
def fill_in(**args)
|
||||||
input.fill_in(**args)
|
input.fill_in(**args)
|
||||||
|
self
|
||||||
end
|
end
|
||||||
|
|
||||||
def value
|
def value
|
||||||
|
@ -105,6 +106,18 @@ module PageObjects
|
||||||
def focused?
|
def focused?
|
||||||
component.has_css?(".chat-composer.is-focused")
|
component.has_css?(".chat-composer.is-focused")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def enter_shortcut
|
||||||
|
input.send_keys(:enter)
|
||||||
|
end
|
||||||
|
|
||||||
|
def shift_enter_shortcut
|
||||||
|
input.send_keys(%i[shift enter])
|
||||||
|
end
|
||||||
|
|
||||||
|
def meta_enter_shortcut
|
||||||
|
input.send_keys([PLATFORM_KEY_MODIFIER, :enter])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -62,6 +62,15 @@ RSpec.describe "User chat preferences", type: :system do
|
||||||
expect(select_kit).to have_selected_value("fullscreen")
|
expect(select_kit).to have_selected_value("fullscreen")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "can select send shorcut sidebar mode" do
|
||||||
|
visit("/my/preferences")
|
||||||
|
find(".user-nav__preferences-chat", visible: :all).click
|
||||||
|
find("#chat_send_shortcut_meta_enter").click
|
||||||
|
find(".save-changes").click
|
||||||
|
|
||||||
|
expect(page).to have_checked_field("chat_send_shortcut_meta_enter")
|
||||||
|
end
|
||||||
|
|
||||||
context "as an admin on another user's preferences" do
|
context "as an admin on another user's preferences" do
|
||||||
fab!(:current_user) { Fabricate(:admin) }
|
fab!(:current_user) { Fabricate(:admin) }
|
||||||
fab!(:user_1) { Fabricate(:user) }
|
fab!(:user_1) { Fabricate(:user) }
|
||||||
|
|
Loading…
Reference in New Issue