diff --git a/.changeset/khaki-oranges-wink.md b/.changeset/khaki-oranges-wink.md new file mode 100644 index 0000000000000..d03418158399e --- /dev/null +++ b/.changeset/khaki-oranges-wink.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +--- + +Added a new formatter shortcut to add hyperlinks to a message diff --git a/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx b/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx new file mode 100644 index 0000000000000..420bf93df66d5 --- /dev/null +++ b/apps/meteor/app/ui-message/client/messageBox/AddLinkComposerActionModal.tsx @@ -0,0 +1,65 @@ +import { Field, FieldGroup, TextInput, FieldLabel, FieldRow, Box } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { useEffect } from 'react'; +import { useForm, Controller } from 'react-hook-form'; + +import GenericModal from '../../../../client/components/GenericModal'; + +type AddLinkComposerActionModalProps = { + selectedText?: string; + onConfirm: (url: string, text: string) => void; + onClose: () => void; +}; + +const AddLinkComposerActionModal = ({ selectedText, onClose, onConfirm }: AddLinkComposerActionModalProps) => { + const t = useTranslation(); + const textField = useUniqueId(); + const urlField = useUniqueId(); + + const { handleSubmit, setFocus, control } = useForm({ + mode: 'onBlur', + defaultValues: { + text: selectedText || '', + url: '', + }, + }); + + useEffect(() => { + setFocus(selectedText ? 'url' : 'text'); + }, [selectedText, setFocus]); + + const onClickConfirm = ({ url, text }: { url: string; text: string }) => { + onConfirm(url, text); + }; + + const submit = handleSubmit(onClickConfirm); + + return ( + void submit(e)} {...props} />} + title={t('Add_link')} + > + + + {t('Text')} + + } /> + + + + {t('URL')} + + } /> + + + + + ); +}; + +export default AddLinkComposerActionModal; diff --git a/apps/meteor/app/ui-message/client/messageBox/messageBoxFormatting.ts b/apps/meteor/app/ui-message/client/messageBox/messageBoxFormatting.ts index 8c170f894aa47..84ca6dcc1035d 100644 --- a/apps/meteor/app/ui-message/client/messageBox/messageBoxFormatting.ts +++ b/apps/meteor/app/ui-message/client/messageBox/messageBoxFormatting.ts @@ -1,24 +1,34 @@ import type { Keys as IconName } from '@rocket.chat/icons'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import type { ComposerAPI } from '../../../../client/lib/chats/ChatAPI'; +import { imperativeModal } from '../../../../client/lib/imperativeModal'; import { settings } from '../../../settings/client'; +import AddLinkComposerActionModal from './AddLinkComposerActionModal'; -export type FormattingButton = - | { - label: TranslationKey; - icon: IconName; - pattern: string; - // text?: () => string | undefined; - command?: string; - link?: string; - condition?: () => boolean; - } - | { - label: TranslationKey; - text: () => string | undefined; - link: string; - condition?: () => boolean; - }; +type FormattingButtonDefault = { label: TranslationKey; condition?: () => boolean }; + +type TextButton = { + text: () => string | undefined; + link: string; +} & FormattingButtonDefault; + +type PatternButton = { + icon: IconName; + pattern: string; + // text?: () => string | undefined; + command?: string; + link?: string; +} & FormattingButtonDefault; + +type PromptButton = { + prompt: (composer: ComposerAPI) => void; + icon: IconName; +} & FormattingButtonDefault; + +export type FormattingButton = PatternButton | PromptButton | TextButton; + +export const isPromptButton = (button: FormattingButton): button is PromptButton => 'prompt' in button; export const formattingButtons: ReadonlyArray = [ { @@ -48,6 +58,28 @@ export const formattingButtons: ReadonlyArray = [ icon: 'multiline', pattern: '```\n{{text}}\n``` ', }, + { + label: 'Link', + icon: 'link', + prompt: (composerApi: ComposerAPI) => { + const { selection } = composerApi; + + const selectedText = composerApi.substring(selection.start, selection.end); + + const onClose = () => { + imperativeModal.close(); + composerApi.focus(); + }; + + const onConfirm = (url: string, text: string) => { + onClose(); + composerApi.replaceText(`[${text}](${url})`, selection); + composerApi.setCursorToEnd(); + }; + + imperativeModal.open({ component: AddLinkComposerActionModal, props: { onConfirm, selectedText, onClose } }); + }, + }, { label: 'KaTeX' as TranslationKey, icon: 'katex', diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxFormattingToolbar/FormattingToolbarDropdown.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxFormattingToolbar/FormattingToolbarDropdown.tsx index 21eb5b94f6d6b..b2ba79792e044 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxFormattingToolbar/FormattingToolbarDropdown.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxFormattingToolbar/FormattingToolbarDropdown.tsx @@ -1,7 +1,7 @@ import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; -import type { FormattingButton } from '../../../../../../app/ui-message/client/messageBox/messageBoxFormatting'; +import { isPromptButton, type FormattingButton } from '../../../../../../app/ui-message/client/messageBox/messageBoxFormatting'; import GenericMenu from '../../../../../components/GenericMenu/GenericMenu'; import type { GenericMenuItemProps } from '../../../../../components/GenericMenu/GenericMenuItem'; import type { ComposerAPI } from '../../../../../lib/chats/ChatAPI'; @@ -21,6 +21,9 @@ const FormattingToolbarDropdown = ({ composer, items, disabled }: FormattingTool window.open(formatter.link, '_blank', 'rel=noreferrer noopener'); return; } + if (isPromptButton(formatter)) { + return formatter.prompt(composer); + } composer.wrapSelection(formatter.pattern); }; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxFormattingToolbar/MessageBoxFormattingToolbar.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxFormattingToolbar/MessageBoxFormattingToolbar.tsx index afe8a58ec72a5..79b210182d77f 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxFormattingToolbar/MessageBoxFormattingToolbar.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxFormattingToolbar/MessageBoxFormattingToolbar.tsx @@ -3,6 +3,7 @@ import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { memo } from 'react'; import type { FormattingButton } from '../../../../../../app/ui-message/client/messageBox/messageBoxFormatting'; +import { isPromptButton } from '../../../../../../app/ui-message/client/messageBox/messageBoxFormatting'; import type { ComposerAPI } from '../../../../../lib/chats/ChatAPI'; import FormattingToolbarDropdown from './FormattingToolbarDropdown'; @@ -24,7 +25,9 @@ const MessageBoxFormattingToolbar = ({ items, variant = 'large', composer, disab <> {'icon' in featuredFormatter && ( composer.wrapSelection(featuredFormatter.pattern)} + onClick={() => + isPromptButton(featuredFormatter) ? featuredFormatter.prompt(composer) : composer.wrapSelection(featuredFormatter.pattern) + } icon={featuredFormatter.icon} disabled={disabled} /> @@ -45,6 +48,10 @@ const MessageBoxFormattingToolbar = ({ items, variant = 'large', composer, disab data-id={formatter.label} title={t(formatter.label)} onClick={(): void => { + if (isPromptButton(formatter)) { + formatter.prompt(composer); + return; + } if ('link' in formatter) { window.open(formatter.link, '_blank', 'rel=noreferrer noopener'); return; diff --git a/apps/meteor/tests/e2e/message-composer.spec.ts b/apps/meteor/tests/e2e/message-composer.spec.ts index 9ed3e42b941d8..8b8888c040a20 100644 --- a/apps/meteor/tests/e2e/message-composer.spec.ts +++ b/apps/meteor/tests/e2e/message-composer.spec.ts @@ -1,3 +1,5 @@ +import { faker } from '@faker-js/faker'; + import { Users } from './fixtures/userStates'; import { HomeChannel } from './page-objects'; import { createTargetChannel } from './utils'; @@ -23,21 +25,18 @@ test.describe.serial('message-composer', () => { await poHomeChannel.sidenav.openChat(targetChannel); await poHomeChannel.content.sendMessage('hello composer'); - await expect(poHomeChannel.composerToolbarActions).toHaveCount(11); + await expect(poHomeChannel.composerToolbarActions).toHaveCount(12); }); test('should have only the main formatter and the main action', async ({ page }) => { await page.setViewportSize({ width: 768, height: 600 }); - await poHomeChannel.sidenav.openChat(targetChannel); - await poHomeChannel.content.sendMessage('hello composer'); await expect(poHomeChannel.composerToolbarActions).toHaveCount(5); }); test('should navigate on toolbar using arrow keys', async ({ page }) => { await poHomeChannel.sidenav.openChat(targetChannel); - await poHomeChannel.content.sendMessage('hello composer'); await page.keyboard.press('Tab'); await page.keyboard.press('ArrowRight'); @@ -50,11 +49,24 @@ test.describe.serial('message-composer', () => { test('should move the focus away from toolbar using tab key', async ({ page }) => { await poHomeChannel.sidenav.openChat(targetChannel); - await poHomeChannel.content.sendMessage('hello composer'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await expect(poHomeChannel.composerToolbar.getByRole('button', { name: 'Emoji' })).not.toBeFocused(); }); + + test('should add a link to the selected text', async ({ page }) => { + const url = faker.internet.url(); + await poHomeChannel.sidenav.openChat(targetChannel); + + await page.keyboard.type('hello composer'); + await page.keyboard.press('Control+A'); // on Windows and Linux + await page.keyboard.press('Meta+A'); // on macOS + await poHomeChannel.composerToolbar.getByRole('button', { name: 'Link' }).click() + await page.keyboard.type(url); + await page.keyboard.press('Enter'); + + await expect(poHomeChannel.composer).toHaveValue(`[hello composer](${url})`); + }); }); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 271907a68caa6..95aadb64a5d56 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -312,6 +312,7 @@ "Add_files_from": "Add files from", "Add_manager": "Add manager", "Add_monitor": "Add monitor", + "Add_link": "Add link", "Add_Reaction": "Add reaction", "Add_Role": "Add Role", "Add_Sender_To_ReplyTo": "Add Sender to Reply-To", @@ -5108,6 +5109,7 @@ "test-push-notifications": "Test push notifications", "test-push-notifications_description": "Permission to test push notifications", "Texts": "Texts", + "Text": "Text", "Thank_you_for_your_feedback": "Thank you for your feedback", "The_application_name_is_required": "The application name is required", "The_application_will_be_able_to": "<1>{{appName}} will be able to:",