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}}1> will be able to:",