Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/khaki-oranges-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/i18n": minor
---

Added a new formatter shortcut to add hyperlinks to a message
Original file line number Diff line number Diff line change
@@ -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 (
<GenericModal
variant='warning'
icon={null}
confirmText={t('Add')}
onCancel={onClose}
wrapperFunction={(props) => <Box is='form' onSubmit={(e) => void submit(e)} {...props} />}
title={t('Add_link')}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor={textField}>{t('Text')}</FieldLabel>
<FieldRow>
<Controller control={control} name='text' render={({ field }) => <TextInput autoComplete='off' id={textField} {...field} />} />
</FieldRow>
</Field>
<Field>
<FieldLabel htmlFor={urlField}>{t('URL')}</FieldLabel>
<FieldRow>
<Controller control={control} name='url' render={({ field }) => <TextInput autoComplete='off' id={urlField} {...field} />} />
</FieldRow>
</Field>
</FieldGroup>
</GenericModal>
);
};

export default AddLinkComposerActionModal;
Original file line number Diff line number Diff line change
@@ -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<FormattingButton> = [
{
Expand Down Expand Up @@ -48,6 +58,28 @@ export const formattingButtons: ReadonlyArray<FormattingButton> = [
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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -24,7 +25,9 @@ const MessageBoxFormattingToolbar = ({ items, variant = 'large', composer, disab
<>
{'icon' in featuredFormatter && (
<MessageComposerAction
onClick={() => composer.wrapSelection(featuredFormatter.pattern)}
onClick={() =>
isPromptButton(featuredFormatter) ? featuredFormatter.prompt(composer) : composer.wrapSelection(featuredFormatter.pattern)
}
icon={featuredFormatter.icon}
disabled={disabled}
/>
Expand All @@ -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;
Expand Down
22 changes: 17 additions & 5 deletions apps/meteor/tests/e2e/message-composer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { faker } from '@faker-js/faker';

import { Users } from './fixtures/userStates';
import { HomeChannel } from './page-objects';
import { createTargetChannel } from './utils';
Expand All @@ -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');
Expand All @@ -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})`);
});
});
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:",
Expand Down