feat: Allow users to select Cmd+Enter as a hotkey (#4401)

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Sivin Varghese 2022-10-04 03:57:34 +05:30 committed by GitHub
parent 9ea43a2678
commit beedfc47bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 344 additions and 93 deletions

View file

@ -0,0 +1,113 @@
<template>
<div class="preview-card--wrap" :class="{ activecard: active }">
<div class="header--wrap" :class="{ active: active }">
<div class="heading-wrap text-block-title">{{ heading }}</div>
<fluent-icon
v-if="active"
icon="checkmark-circle"
type="solid"
size="24"
class="checkmark"
/>
</div>
<div class="content-wrap">
{{ content }}
</div>
<div class="image-wrap">
<img :src="src" class="image" :class="{ activeimage: active }" />
</div>
</div>
</template>
<script>
export default {
props: {
heading: {
type: String,
default: '',
},
content: {
type: String,
default: '',
},
active: {
type: Boolean,
default: false,
},
buttonText: {
type: String,
default: 'Active',
},
src: {
type: String,
default: '',
},
},
};
</script>
<style lang="scss" scoped>
.preview-card--wrap {
border-radius: var(--border-radius-normal);
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
max-height: 34rem;
max-width: 38rem;
min-width: 24rem;
.header--wrap {
background: var(--s-50);
border-bottom: 1px solid var(--color-border);
border-top-left-radius: var(--border-radius-normal);
border-top-right-radius: var(--border-radius-normal);
display: flex;
height: 4rem;
justify-content: space-between;
padding: var(--space-small);
width: 100%;
}
.active {
background: var(--w-50);
border-bottom: 1px solid var(--w-75);
}
.heading-wrap {
align-items: center;
display: flex;
font-weight: var(--font-weight-medium);
padding: var(--space-smaller);
}
.checkmark {
color: var(--w-500);
}
.content-wrap {
color: var(--s-700);
font-size: var(--font-size-mini);
line-height: 1.4;
padding: var(--space-slab) var(--space-slab) 0 var(--space-slab);
text-align: start;
}
.image-wrap {
padding: var(--space-slab);
}
.image {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-normal);
}
.activeimage {
border: 1px solid var(--w-75);
}
}
.activecard {
background: var(--w-25);
border: 1px solid var(--w-300);
}
</style>

View file

@ -91,17 +91,6 @@
</transition>
</div>
<div class="right-wrap">
<div v-if="isFormatMode" class="enter-to-send--checkbox">
<input
:checked="enterToSendEnabled"
type="checkbox"
value="enterToSend"
@input="toggleEnterToSend"
/>
<label for="enterToSend">
{{ $t('CONVERSATION.REPLYBOX.ENTER_TO_SEND') }}
</label>
</div>
<woot-button
size="small"
:class-names="buttonClass"
@ -201,10 +190,6 @@ export default {
type: Boolean,
default: false,
},
enterToSendEnabled: {
type: Boolean,
default: true,
},
enableMultipleFileUpload: {
type: Boolean,
default: true,
@ -278,9 +263,6 @@ export default {
this.$refs.upload.$children[1].$el.click();
}
},
toggleEnterToSend() {
this.$emit('toggleEnterToSend', !this.enterToSendEnabled);
},
toggleMessageSignature() {
this.updateUISettings({
send_with_signature: !this.sendWithSignature,
@ -312,20 +294,6 @@ export default {
.right-wrap {
display: flex;
.enter-to-send--checkbox {
align-items: center;
display: flex;
input {
margin: 0;
}
label {
color: var(--s-500);
font-size: var(--font-size-mini);
}
}
}
::v-deep .file-uploads {

View file

@ -110,10 +110,8 @@
:is-recording-audio="isRecordingAudio"
:is-on-private-note="isOnPrivateNote"
:is-format-mode="showRichContentEditor"
:enter-to-send-enabled="enterToSendEnabled"
:enable-multiple-file-upload="enableMultipleFileUpload"
:has-whatsapp-templates="hasWhatsappTemplates"
@toggleEnterToSend="toggleEnterToSend"
@selectWhatsappTemplate="openWhatsappTemplateModal"
/>
<whatsapp-templates
@ -150,18 +148,14 @@ import {
} from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import {
isEscape,
isEnter,
hasPressedShift,
hasPressedCommandPlusKKey,
} from 'shared/helpers/KeyboardHelpers';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { DirectUpload } from 'activestorage';
import { frontendURL } from '../../../helper/URLHelper';
import wootConstants from 'dashboard/constants';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
export default {
components: {
@ -267,9 +261,6 @@ export default {
return !!this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
.length;
},
enterToSendEnabled() {
return !!this.uiSettings.enter_to_send_enabled;
},
isPrivate() {
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
return this.isOnPrivateNote;
@ -343,13 +334,16 @@ export default {
);
},
replyButtonLabel() {
let sendMessageText = this.$t('CONVERSATION.REPLYBOX.SEND');
if (this.isPrivate) {
return this.$t('CONVERSATION.REPLYBOX.CREATE');
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
} else if (this.conversationType === 'tweet') {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.TWEET');
}
if (this.conversationType === 'tweet') {
return this.$t('CONVERSATION.REPLYBOX.TWEET');
}
return this.$t('CONVERSATION.REPLYBOX.SEND');
const keyLabel = isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter')
? '(⌘ + ↵)'
: '(↵)';
return `${sendMessageText} ${keyLabel}`;
},
replyBoxClass() {
return {
@ -430,12 +424,18 @@ export default {
profilePath() {
return frontendURL(`accounts/${this.accountId}/profile/settings`);
},
conversationId() {
return this.currentChat.id;
editorMessageKey() {
const { editor_message_key: isEnabled } = this.uiSettings;
return isEnabled;
},
commandPlusEnterToSendEnabled() {
return this.editorMessageKey === 'cmd_enter';
},
enterToSendEnabled() {
return this.editorMessageKey === 'enter';
},
editorStateId() {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
return key;
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
},
watch: {
@ -471,16 +471,41 @@ export default {
mounted() {
// Donot use the keyboard listener mixin here as the events here are supposed to be
// working even if input/textarea is focussed.
document.addEventListener('keydown', this.handleKeyEvents);
document.addEventListener('paste', this.onPaste);
document.addEventListener('keydown', this.handleKeyEvents);
this.setCCEmailFromLastChat();
},
destroyed() {
document.removeEventListener('keydown', this.handleKeyEvents);
document.removeEventListener('paste', this.onPaste);
document.removeEventListener('keydown', this.handleKeyEvents);
},
methods: {
handleKeyEvents(e) {
const keyCode = buildHotKeys(e);
if (keyCode === 'escape') {
this.hideEmojiPicker();
this.hideMentions();
} else if (keyCode === 'meta+k') {
const ninja = document.querySelector('ninja-keys');
ninja.open();
e.preventDefault();
} else if (keyCode === 'enter' && this.isAValidEvent('enter')) {
this.onSendReply();
} else if (
['meta+enter', 'ctrl+enter'].includes(keyCode) &&
this.isAValidEvent('cmd_enter')
) {
this.onSendReply();
}
},
isAValidEvent(selectedKey) {
return (
!this.hasUserMention &&
!this.showCannedMenu &&
this.isFocused &&
isEditorHotKeyEnabled(this.uiSettings, selectedKey)
);
},
onPaste(e) {
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
@ -500,34 +525,6 @@ export default {
toggleCannedMenu(value) {
this.showCannedMenu = value;
},
handleKeyEvents(e) {
if (isEscape(e)) {
this.hideEmojiPicker();
this.hideMentions();
} else if (isEnter(e)) {
const hasSendOnEnterEnabled =
(this.showRichContentEditor &&
this.enterToSendEnabled &&
!this.hasUserMention &&
!this.showCannedMenu) ||
!this.showRichContentEditor;
const shouldSendMessage =
hasSendOnEnterEnabled && !hasPressedShift(e) && this.isFocused;
if (shouldSendMessage) {
e.preventDefault();
this.onSendReply();
}
} else if (hasPressedCommandPlusKKey(e)) {
this.openCommandBar();
}
},
openCommandBar() {
const ninja = document.querySelector('ninja-keys');
ninja.open();
},
toggleEnterToSend(enterToSendEnabled) {
this.updateUISettings({ enter_to_send_enabled: enterToSendEnabled });
},
openWhatsappTemplateModal() {
this.showWhatsAppTemplatesModal = true;
},
@ -603,7 +600,6 @@ export default {
},
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
const { can_reply: canReply } = this.currentChat;
if (canReply || this.isAWhatsAppChannel) this.replyType = mode;
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {

View file

@ -111,7 +111,6 @@
"TIP_AUDIORECORDER_ICON": "Record audio",
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"ENTER_TO_SEND": "Enter to send",
"DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",

View file

@ -19,6 +19,21 @@
"TITLE": "Profile",
"NOTE": "Your email address is your identity and is used to log in."
},
"SEND_MESSAGE": {
"TITLE": "Hotkey to send messages",
"NOTE": "You can select a hotkey (either Enter or Cmd/Ctrl+Enter) based on your preference of writing.",
"UPDATE_SUCCESS": "Your settings have been updated successfully",
"CARD": {
"ENTER_KEY": {
"HEADING": "Enter (↵)",
"CONTENT": "Send messages by pressing Enter key instead of clicking the send button."
},
"CMD_ENTER_KEY": {
"HEADING": "Cmd/Ctrl + Enter (⌘ + ↵)",
"CONTENT": "Send messages by pressing Cmd/Ctrl + enter key instead of clicking the send button."
}
}
},
"MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature",
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",

View file

@ -2,6 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import uiSettingsMixin, {
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
isEditorHotKeyEnabled,
} from '../uiSettings';
import Vuex from 'vuex';
const localVue = createLocalVue();
@ -137,3 +138,26 @@ describe('uiSettingsMixin', () => {
});
});
});
describe('isEditorHotKeyEnabled', () => {
it('returns true if hot key is not configured and enter to send flag is true', () => {
expect(
isEditorHotKeyEnabled({ enter_to_send_enabled: true }, 'enter')
).toEqual(true);
expect(
isEditorHotKeyEnabled({ enter_to_send_enabled: true }, 'cmd_enter')
).toEqual(false);
expect(isEditorHotKeyEnabled({}, 'cmd_enter')).toEqual(true);
expect(isEditorHotKeyEnabled({}, 'enter')).toEqual(false);
});
it('returns correct value if hot key is configured', () => {
expect(
isEditorHotKeyEnabled({ editor_message_key: 'enter' }, 'enter')
).toEqual(true);
expect(
isEditorHotKeyEnabled({ editor_message_key: 'cmd_enter' }, 'enter')
).toEqual(false);
});
});

View file

@ -10,11 +10,24 @@ export const DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER = [
{ name: 'contact_labels' },
{ name: 'previous_conversation' },
];
export const isEditorHotKeyEnabled = (uiSettings, key) => {
const {
editor_message_key: editorMessageKey,
enter_to_send_enabled: enterToSendEnabled,
} = uiSettings || {};
if (!editorMessageKey) {
if (enterToSendEnabled) {
return key === 'enter';
}
return key === 'cmd_enter';
}
return editorMessageKey === key;
};
export default {
computed: {
...mapGetters({
uiSettings: 'getUISettings',
}),
...mapGetters({ uiSettings: 'getUISettings' }),
conversationSidebarItemsOrder() {
const { conversation_sidebar_items_order: itemsOrder } = this.uiSettings;
return itemsOrder || DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER;

View file

@ -70,6 +70,31 @@
</div>
</form>
<message-signature />
<div class="profile--settings--row row">
<div class="columns small-3">
<h4 class="block-title">
{{ $t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.TITLE') }}
</h4>
<p>
{{ $t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.NOTE') }}
</p>
</div>
<div class="columns small-9 medium-5 card-preview">
<button
v-for="keyOption in keyOptions"
:key="keyOption.key"
class="preview-button"
@click="toggleEditorMessageKey(keyOption.key)"
>
<preview-card
:heading="keyOption.heading"
:content="keyOption.content"
:src="keyOption.src"
:active="isEditorHotKeyEnabled(uiSettings, keyOption.key)"
/>
</button>
</div>
</div>
<change-password v-if="!globalConfig.disableUserProfileUpdate" />
<notification-settings />
<div class="profile--settings--row row">
@ -102,14 +127,19 @@ import alertMixin from 'shared/mixins/alertMixin';
import ChangePassword from './ChangePassword';
import MessageSignature from './MessageSignature';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import uiSettingsMixin, {
isEditorHotKeyEnabled,
} from 'dashboard/mixins/uiSettings';
import PreviewCard from 'dashboard/components/ui/PreviewCard.vue';
export default {
components: {
NotificationSettings,
ChangePassword,
MessageSignature,
PreviewCard,
},
mixins: [alertMixin, globalConfigMixin],
mixins: [alertMixin, globalConfigMixin, uiSettingsMixin],
data() {
return {
avatarFile: '',
@ -119,6 +149,28 @@ export default {
email: '',
isProfileUpdating: false,
errorMessage: '',
keyOptions: [
{
key: 'enter',
src: '/assets/images/dashboard/editor/enter-editor.png',
heading: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.ENTER_KEY.HEADING'
),
content: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.ENTER_KEY.CONTENT'
),
},
{
key: 'cmd_enter',
src: '/assets/images/dashboard/editor/cmd-editor.png',
heading: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.CMD_ENTER_KEY.HEADING'
),
content: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.CMD_ENTER_KEY.CONTENT'
),
},
],
};
},
validations: {
@ -158,6 +210,7 @@ export default {
this.avatarUrl = this.currentUser.avatar_url;
this.displayName = this.currentUser.display_name;
},
isEditorHotKeyEnabled,
async updateUser() {
this.$v.$touch();
if (this.$v.$invalid) {
@ -207,6 +260,12 @@ export default {
showDeleteButton() {
return this.avatarUrl && !this.avatarUrl.includes('www.gravatar.com');
},
toggleEditorMessageKey(key) {
this.updateUISettings({ editor_message_key: key });
this.showAlert(
this.$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.UPDATE_SUCCESS')
);
},
},
};
</script>
@ -216,18 +275,32 @@ export default {
@import '~dashboard/assets/scss/mixins.scss';
.profile--settings {
padding: 24px;
overflow: auto;
padding: 24px;
}
.profile--settings--row {
@include border-normal-bottom;
align-items: center;
display: flex;
padding: $space-normal;
.small-3 {
padding: $space-normal $space-medium $space-normal 0;
}
.small-9 {
padding: $space-normal;
}
.card-preview {
display: flex;
flex-direction: row;
.preview-button {
cursor: pointer;
margin-right: var(--space-normal);
}
}
}
</style>

View file

@ -77,7 +77,7 @@ export default {
methods: {
initValues() {
const { message_signature: messageSignature } = this.currentUser;
this.messageSignature = messageSignature;
this.messageSignature = messageSignature || '';
},
async updateSignature() {
this.$v.$touch();

View file

@ -154,7 +154,7 @@
)
}}
</p>
<div v-else>
<div v-else class="push-notification--button">
<woot-submit-button
:button-text="
$t(
@ -368,4 +368,8 @@ export default {
.notification--checkbox {
font-size: $font-size-large;
}
.push-notification--button {
margin-bottom: var(--space-one);
}
</style>

View file

@ -10,6 +10,10 @@ export const hasPressedShift = e => {
return e.shiftKey;
};
export const hasPressedCommand = e => {
return e.metaKey;
};
export const hasPressedCommandAndEnter = e => {
return e.metaKey && e.keyCode === 13;
};
@ -89,3 +93,25 @@ export const hasPressedArrowDownKey = e => {
export const hasPressedCommandPlusKKey = e => {
return e.metaKey && e.keyCode === 75;
};
export const buildHotKeys = e => {
const key = e.key.toLowerCase();
if (['shift', 'meta', 'alt', 'control'].includes(key)) {
return key;
}
let hotKeyPattern = '';
if (e.altKey) {
hotKeyPattern += 'alt+';
}
if (e.ctrlKey) {
hotKeyPattern += 'ctrl+';
}
if (e.metaKey && !e.ctrlKey) {
hotKeyPattern += 'meta+';
}
if (e.shiftKey) {
hotKeyPattern += 'shift+';
}
hotKeyPattern += key;
return hotKeyPattern;
};

View file

@ -1,4 +1,10 @@
import { isEnter, isEscape, hasPressedShift } from '../KeyboardHelpers';
import {
isEnter,
isEscape,
hasPressedShift,
hasPressedCommand,
buildHotKeys,
} from '../KeyboardHelpers';
describe('#KeyboardHelpers', () => {
describe('#isEnter', () => {
@ -18,4 +24,18 @@ describe('#KeyboardHelpers', () => {
expect(hasPressedShift({ shiftKey: true })).toEqual(true);
});
});
describe('#hasPressedCommand', () => {
it('return correct values', () => {
expect(hasPressedCommand({ metaKey: true })).toEqual(true);
});
});
describe('#buildHotKeys', () => {
it('returns hot keys', () => {
expect(buildHotKeys({ altKey: true, key: 'alt' })).toEqual('alt');
expect(buildHotKeys({ ctrlKey: true, key: 'a' })).toEqual('ctrl+a');
expect(buildHotKeys({ metaKey: true, key: 'b' })).toEqual('meta+b');
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB