diff --git a/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_account_settings_spec.js b/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_account_settings_spec.js index 03bc4d5bda..8c0c701c5a 100644 --- a/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_account_settings_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_account_settings_spec.js @@ -50,6 +50,7 @@ describe('Verify Accessibility Support in different sections in Settings and Pro {key: 'click_to_reply', label: 'Click to open threads', type: 'radio'}, {key: 'channel_display_mode', label: 'Channel Display', type: 'radio'}, {key: 'one_click_reactions_enabled', label: 'Quick reactions on messages', type: 'radio'}, + {key: 'renderEmoticonsAsEmoji', label: 'Render emoticons as emojis', type: 'radio'}, {key: 'languages', label: 'Language', type: 'dropdown'}, ], sidebar: [ diff --git a/webapp/channels/src/components/post_markdown/index.ts b/webapp/channels/src/components/post_markdown/index.ts index 18d50ff40f..6172d960bf 100644 --- a/webapp/channels/src/components/post_markdown/index.ts +++ b/webapp/channels/src/components/post_markdown/index.ts @@ -82,6 +82,7 @@ function makeMapStateToProps() { hideGuestTags: getConfig(state).HideGuestTags === 'true', isEnterpriseOrCloudOrSKUStarterFree: isEnterpriseOrCloudOrSKUStarterFree(license, subscriptionProduct, isEnterpriseReady), isEnterpriseReady, + renderEmoticonsAsEmoji: getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.RENDER_EMOTICONS_AS_EMOJI, Preferences.RENDER_EMOTICONS_AS_EMOJI_DEFAULT === 'true'), }; }; } diff --git a/webapp/channels/src/components/post_markdown/post_markdown.test.tsx b/webapp/channels/src/components/post_markdown/post_markdown.test.tsx index 1af96ceb76..aa9541c422 100644 --- a/webapp/channels/src/components/post_markdown/post_markdown.test.tsx +++ b/webapp/channels/src/components/post_markdown/post_markdown.test.tsx @@ -32,6 +32,7 @@ describe('components/PostMarkdown', () => { isEnterpriseOrCloudOrSKUStarterFree: true, isEnterpriseReady: false, dispatch: jest.fn(), + renderEmoticonsAsEmoji: true, }; const state = {entities: { diff --git a/webapp/channels/src/components/post_markdown/post_markdown.tsx b/webapp/channels/src/components/post_markdown/post_markdown.tsx index c36786f336..c67c503651 100644 --- a/webapp/channels/src/components/post_markdown/post_markdown.tsx +++ b/webapp/channels/src/components/post_markdown/post_markdown.tsx @@ -40,6 +40,11 @@ export type OwnProps = { */ showPostEditedIndicator?: boolean; options?: TextFormattingOptions; + + /** + * Whether or not to render text emoticons (:D) as emojis + */ + renderEmoticonsAsEmoji?: boolean; }; type Props = PropsFromRedux & OwnProps; @@ -52,12 +57,13 @@ export default class PostMarkdown extends React.PureComponent { }; getOptions = memoize( - (options?: TextFormattingOptions, disableGroupHighlight?: boolean, mentionHighlight?: boolean, editedAt?: number) => { + (options?: TextFormattingOptions, disableGroupHighlight?: boolean, mentionHighlight?: boolean, editedAt?: number, renderEmoticonsAsEmoji?: boolean) => { return { ...options, disableGroupHighlight, mentionHighlight, editedAt, + renderEmoticonsAsEmoji, }; }); @@ -110,6 +116,7 @@ export default class PostMarkdown extends React.PureComponent { this.props.post?.props?.disable_group_highlight === true, mentionHighlight, this.props.post?.edit_at, + this.props?.renderEmoticonsAsEmoji, ); let highlightKeys; diff --git a/webapp/channels/src/components/user_settings/display/__snapshots__/user_settings_display.test.tsx.snap b/webapp/channels/src/components/user_settings/display/__snapshots__/user_settings_display.test.tsx.snap index 8430a0e882..5f7e2d5458 100644 --- a/webapp/channels/src/components/user_settings/display/__snapshots__/user_settings_display.test.tsx.snap +++ b/webapp/channels/src/components/user_settings/display/__snapshots__/user_settings_display.test.tsx.snap @@ -314,6 +314,48 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps className="divider-dark" /> +
+ + } + max={ + + } + section="renderEmoticonsAsEmoji" + title={ + + } + updateSection={[Function]} + /> +
+
+
+ + } + max={ + + } + section="renderEmoticonsAsEmoji" + title={ + + } + updateSection={[Function]} + /> +
+
+
+ + } + max={ + + } + section="renderEmoticonsAsEmoji" + title={ + + } + updateSection={[Function]} + /> +
+
+
+ + } + max={ + + } + section="renderEmoticonsAsEmoji" + title={ + + } + updateSection={[Function]} + /> +
+
+
+ + } + max={ + + } + section="renderEmoticonsAsEmoji" + title={ + + } + updateSection={[Function]} + /> +
+
+
+ + } + max={ + + } + section="renderEmoticonsAsEmoji" + title={ + + } + updateSection={[Function]} + /> +
+
+
+ + } + max={ + + } + section="renderEmoticonsAsEmoji" + title={ + + } + updateSection={[Function]} + /> +
+
+
+ + } + max={ + + } + section="renderEmoticonsAsEmoji" + title={ + + } + updateSection={[Function]} + /> +
+
+
+ + } + max={ + + } + section="renderEmoticonsAsEmoji" + title={ + + } + updateSection={[Function]} + /> +
+
+
+ + } + max={ + + } + section="renderEmoticonsAsEmoji" + title={ + + } + updateSection={[Function]} + /> +
+
+
+ + } + max={ + + } + section="renderEmoticonsAsEmoji" + title={ + + } + updateSection={[Function]} + /> +
+
+
+ + } + max={ + + } + section="renderEmoticonsAsEmoji" + title={ + + } + updateSection={[Function]} + /> +
+
+
+ + } + max={ + + } + section="renderEmoticonsAsEmoji" + title={ + + } + updateSection={[Function]} + /> +
+
+
+ + } + max={ + + } + section="renderEmoticonsAsEmoji" + title={ + + } + updateSection={[Function]} + /> +
+
+
+

+ Render emoticons as emojis +

+
+
+
+
+ + Render emoticons as emojis + +
+ +
+
+
+ +
+
+
+ When enabled, text emoticons in messages will be rendered as emojis (For example :D as 😄) +
+
+
+
+
+ + +
+
+
+
+
+`; diff --git a/webapp/channels/src/components/user_settings/display/render_emoticons_as_emoji/index.ts b/webapp/channels/src/components/user_settings/display/render_emoticons_as_emoji/index.ts new file mode 100644 index 0000000000..c93c67364f --- /dev/null +++ b/webapp/channels/src/components/user_settings/display/render_emoticons_as_emoji/index.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import type {Dispatch} from 'redux'; + +import {savePreferences} from 'mattermost-redux/actions/preferences'; + +import RenderEmoticonsAsEmoji from './render_emoticons_as_emoji'; + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + savePreferences, + }, dispatch), + }; +} + +export default connect(null, mapDispatchToProps)(RenderEmoticonsAsEmoji); diff --git a/webapp/channels/src/components/user_settings/display/render_emoticons_as_emoji/render_emoticons_as_emoji.test.tsx b/webapp/channels/src/components/user_settings/display/render_emoticons_as_emoji/render_emoticons_as_emoji.test.tsx new file mode 100644 index 0000000000..9107caa9a5 --- /dev/null +++ b/webapp/channels/src/components/user_settings/display/render_emoticons_as_emoji/render_emoticons_as_emoji.test.tsx @@ -0,0 +1,56 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import type {UserProfile} from '@mattermost/types/users'; + +import {renderWithContext, userEvent} from 'tests/react_testing_utils'; + +import RenderEmoticonsAsEmoji from './render_emoticons_as_emoji'; + +describe('components/user_settings/display/render_emoticons_as_emoji/render_emoticons_as_emoji', () => { + const user = { + id: 'user_id', + username: 'username', + locale: 'en', + timezone: { + useAutomaticTimezone: 'true', + automaticTimezone: 'America/New_York', + manualTimezone: '', + }, + }; + + const props = { + user: user as UserProfile, + renderEmoticonsAsEmoji: 'true', + updateSection: jest.fn(), + adminMode: false, + userPreferences: undefined, + actions: { + savePreferences: jest.fn(() => { + return new Promise((resolve) => { + process.nextTick(() => resolve()); + }); + })}, + }; + + test('should match snapshot', () => { + const {container} = renderWithContext(); + + expect(container).toMatchSnapshot(); + }); + + test('should call updateSection on submit', async () => { + const {getByRole, getByLabelText} = renderWithContext(); + + const radioButtonOff = getByLabelText(/off/i); + userEvent.click(radioButtonOff); + + const submitButton = getByRole('button', {name: /save/i}); + userEvent.click(submitButton); + + expect(props.actions.savePreferences).toHaveBeenCalled(); + expect(props.updateSection).toHaveBeenCalledWith(''); + }); +}); diff --git a/webapp/channels/src/components/user_settings/display/render_emoticons_as_emoji/render_emoticons_as_emoji.tsx b/webapp/channels/src/components/user_settings/display/render_emoticons_as_emoji/render_emoticons_as_emoji.tsx new file mode 100644 index 0000000000..d27e42dfa9 --- /dev/null +++ b/webapp/channels/src/components/user_settings/display/render_emoticons_as_emoji/render_emoticons_as_emoji.tsx @@ -0,0 +1,133 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useState} from 'react'; +import {FormattedMessage} from 'react-intl'; + +import type {PreferencesType, PreferenceType} from '@mattermost/types/preferences'; +import type {UserProfile} from '@mattermost/types/users'; + +import {Preferences} from 'mattermost-redux/constants'; + +import SettingItemMax from 'components/setting_item_max'; + +export type Actions = { + savePreferences: (userId: string, preferences: PreferenceType[]) => void; +}; + +export type OwnProps = { + user: UserProfile; + updateSection: (section: string) => void; + adminMode?: boolean; + userPreferences?: PreferencesType; +} + +type Props = OwnProps & { + renderEmoticonsAsEmoji: string; + actions: Actions; +} + +const RenderEmoticonsAsEmoji: React.FC = ({user, renderEmoticonsAsEmoji, updateSection, actions}) => { + const [value, setValue] = useState(renderEmoticonsAsEmoji); + const [isSaving, setIsSaving] = useState(false); + + const handleChange = useCallback((e: React.ChangeEvent) => { + setValue(e.currentTarget.value); + }, []); + + const submitPreference = useCallback(() => { + setIsSaving(true); + const pref: PreferenceType = { + user_id: user.id, + category: Preferences.CATEGORY_DISPLAY_SETTINGS, + name: Preferences.RENDER_EMOTICONS_AS_EMOJI, + value, + }; + actions.savePreferences(user.id, [pref]); + setIsSaving(false); + updateSection(''); + }, [user.id, updateSection, actions, value]); + + const changePreference = useCallback(() => { + if (value === renderEmoticonsAsEmoji) { + updateSection(''); + return; + } + + submitPreference(); + }, [renderEmoticonsAsEmoji, updateSection, value, submitPreference]); + + const options = [ + { + option: 'true', + inputId: 'renderEmoticonsAsEmojiOn', + messageId: 'user.settings.advance.on', + defaultMessage: 'On', + }, + { + option: 'false', + inputId: 'renderEmoticonsAsEmojiOff', + messageId: 'user.settings.advance.off', + defaultMessage: 'Off', + }, + ]; + + const input = ( +
+ + + + {options.map(({option, inputId, messageId, defaultMessage}) => { + return ( +
+ +
+
+ ); + })} +
+ +
+
+ ); + + return ( + + } + inputs={[input]} + submit={changePreference} + saving={isSaving} + updateSection={updateSection} + disableEnterSubmit={true} + /> + ); +}; + +export default RenderEmoticonsAsEmoji; diff --git a/webapp/channels/src/components/user_settings/display/user_settings_display.test.tsx b/webapp/channels/src/components/user_settings/display/user_settings_display.test.tsx index 17cdddf666..7b4c6beb4c 100644 --- a/webapp/channels/src/components/user_settings/display/user_settings_display.test.tsx +++ b/webapp/channels/src/components/user_settings/display/user_settings_display.test.tsx @@ -94,6 +94,7 @@ describe('components/user_settings/display/UserSettingsDisplay', () => { globalHeaderAllowed: true, lastActiveDisplay: true, oneClickReactionsOnPosts: '', + renderEmoticonsAsEmoji: '', emojiPickerEnabled: true, clickToReply: '', lastActiveTimeEnabled: true, diff --git a/webapp/channels/src/components/user_settings/display/user_settings_display.tsx b/webapp/channels/src/components/user_settings/display/user_settings_display.tsx index 6892e9d93d..9f3371efe7 100644 --- a/webapp/channels/src/components/user_settings/display/user_settings_display.tsx +++ b/webapp/channels/src/components/user_settings/display/user_settings_display.tsx @@ -28,6 +28,7 @@ import {a11yFocus} from 'utils/utils'; import ManageLanguages from './manage_languages'; import ManageTimezones from './manage_timezones'; +import RenderEmoticonsAsEmoji from './render_emoticons_as_emoji'; import SettingDesktopHeader from '../headers/setting_desktop_header'; import SettingMobileHeader from '../headers/setting_mobile_header'; @@ -120,6 +121,7 @@ type Props = OwnProps & { timezoneLabel: string; lastActiveDisplay: boolean; lastActiveTimeEnabled: boolean; + renderEmoticonsAsEmoji: string; actions: { savePreferences: (userId: string, preferences: PreferenceType[]) => void; autoUpdateTimezone: (deviceTimezone: string) => void; @@ -1147,6 +1149,46 @@ export default class UserSettingsDisplay extends React.PureComponent + + } + describe={ + this.props.renderEmoticonsAsEmoji === 'true' ? ( + + ) : ( + + ) + } + section='renderEmoticonsAsEmoji' + updateSection={this.updateSection} + max={( + + )} + /> +
+
+ ); + return (
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 510358dd37..ee6d2050ab 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -5903,6 +5903,8 @@ "user.settings.display.oneClickReactionsOnPostsDescription": "When enabled, you can react quickly with recently used reactions when hovering over a message.", "user.settings.display.oneClickReactionsOnPostsTitle": "Quick reactions on messages", "user.settings.display.preferTime": "Select how you prefer time displayed.", + "user.settings.display.renderEmoticonsAsEmojiDesc": "When enabled, text emoticons in messages will automatically be rendered as emojis (For example :D as 😄)", + "user.settings.display.renderEmoticonsAsEmojiTitle": "Render emoticons as emojis", "user.settings.display.teammateNameDisplay": "This field is handled through your System Administrator. If you want to change it, you need to do so through your System Administrator.", "user.settings.display.teammateNameDisplayDescription": "Set how to display other user's names in posts and the Direct Messages list.", "user.settings.display.teammateNameDisplayFullname": "Show first and last name", diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/preferences.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/preferences.ts index d8e84353e2..3f6e7ae905 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/constants/preferences.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/constants/preferences.ts @@ -48,6 +48,7 @@ const Preferences = { SHOW_UNREAD_SECTION: 'show_unread_section', CATEGORY_ADVANCED_SETTINGS: 'advanced_settings', ADVANCED_FILTER_JOIN_LEAVE: 'join_leave', + RENDER_EMOTICONS_AS_EMOJI: 'render_emoticons_as_emoji', ADVANCED_CODE_BLOCK_ON_CTRL_ENTER: 'code_block_ctrl_enter', ADVANCED_SEND_ON_CTRL_ENTER: 'send_on_ctrl_enter', ADVANCED_SYNC_DRAFTS: 'sync_drafts', diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index c58982c9f2..ad33c85134 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -91,6 +91,8 @@ export const Preferences = { CLICK_TO_REPLY: 'click_to_reply', CLICK_TO_REPLY_DEFAULT: 'true', COLLAPSED_REPLY_THREADS_FALLBACK_DEFAULT: 'off', + RENDER_EMOTICONS_AS_EMOJI: 'render_emoticons_as_emoji', + RENDER_EMOTICONS_AS_EMOJI_DEFAULT: 'true', LINK_PREVIEW_DISPLAY: 'link_previews', LINK_PREVIEW_DISPLAY_DEFAULT: 'true', COLLAPSE_DISPLAY: 'collapse_previews', diff --git a/webapp/channels/src/utils/emoticons.test.tsx b/webapp/channels/src/utils/emoticons.test.tsx index 53ff214d4c..3c27e3a2fd 100644 --- a/webapp/channels/src/utils/emoticons.test.tsx +++ b/webapp/channels/src/utils/emoticons.test.tsx @@ -92,6 +92,11 @@ describe('Emoticons', () => { expect(Emoticons.handleEmoticons('":P"', new Map())). toEqual('"$MM_EMOTICON0$"'); }); + + test('shouldn\'t render text-based emoticons as emoji when renderEmoticonsAsEmoji is false', () => { + expect(Emoticons.handleEmoticons('":P"', new Map(), false)). + toEqual('":P"'); + }); }); describe('matchEmoticons', () => { diff --git a/webapp/channels/src/utils/emoticons.tsx b/webapp/channels/src/utils/emoticons.tsx index 51cdd5923f..d9446e4621 100644 --- a/webapp/channels/src/utils/emoticons.tsx +++ b/webapp/channels/src/utils/emoticons.tsx @@ -50,6 +50,7 @@ export function matchEmoticons(text: string): string[] | null { export function handleEmoticons( text: string, tokens: Map, + renderEmoticonsAsEmoji = true, ): string { let output = text; @@ -85,12 +86,14 @@ export function handleEmoticons( ); // match text smilies like :D - for (const name of Object.keys(emoticonPatterns)) { - const pattern = emoticonPatterns[name]; + if (renderEmoticonsAsEmoji) { + for (const name of Object.keys(emoticonPatterns)) { + const pattern = emoticonPatterns[name]; - // this might look a bit funny, but since the name isn't contained in the actual match - // like with the named emoticons, we need to add it in manually - output = output.replace(pattern, (fullMatch, prefix, matchText) => replaceEmoticonWithToken(fullMatch, prefix, matchText, name)); + // this might look a bit funny, but since the name isn't contained in the actual match + // like with the named emoticons, we need to add it in manually + output = output.replace(pattern, (fullMatch, prefix, matchText) => replaceEmoticonWithToken(fullMatch, prefix, matchText, name)); + } } return output; diff --git a/webapp/channels/src/utils/text_formatting.test.ts b/webapp/channels/src/utils/text_formatting.test.ts index 494fb6c87d..2be600dded 100644 --- a/webapp/channels/src/utils/text_formatting.test.ts +++ b/webapp/channels/src/utils/text_formatting.test.ts @@ -90,6 +90,11 @@ describe('formatText', () => { } }); + test('emoticons should not be rendered as emojis when renderEmoticonsAsEmoji is false', () => { + const output = formatText(':)', {renderEmoticonsAsEmoji: false}, emptyEmojiMap); + expect(output).toBe('

:)

'); + }); + test('code blocks newlines are not converted into
with inline markdown image in the post', () => { const output = formatText('```\nsome text\nsecond line\n```\n ![](https://example.com/image.png)', {}, emptyEmojiMap); expect(output).not.toContain('
'); diff --git a/webapp/channels/src/utils/text_formatting.tsx b/webapp/channels/src/utils/text_formatting.tsx index e1b5287253..02e7c827fd 100644 --- a/webapp/channels/src/utils/text_formatting.tsx +++ b/webapp/channels/src/utils/text_formatting.tsx @@ -230,6 +230,11 @@ export interface TextFormattingOptionsBase { * Defaults to `false`. */ unsafeLinks: boolean; + + /** + * Whether or not to render text emoticons (:D) as emojis + */ + renderEmoticonsAsEmoji: boolean; } export type TextFormattingOptions = Partial; @@ -258,6 +263,7 @@ const DEFAULT_OPTIONS: TextFormattingOptions = { editedAt: 0, postId: '', unsafeLinks: false, + renderEmoticonsAsEmoji: true, }; /** @@ -401,7 +407,7 @@ export function doFormatText(text: string, options: TextFormattingOptions, emoji output = autolinkHashtags(output, tokens, options.minimumHashtagLength); if (!('emoticons' in options) || options.emoticons) { - output = Emoticons.handleEmoticons(output, tokens); + output = Emoticons.handleEmoticons(output, tokens, options.renderEmoticonsAsEmoji); } if (options.searchPatterns) {