diff --git a/app/components/floating_text_chips_input/index.tsx b/app/components/floating_text_chips_input/index.tsx new file mode 100644 index 000000000..ac5252d06 --- /dev/null +++ b/app/components/floating_text_chips_input/index.tsx @@ -0,0 +1,328 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, { + useState, + useRef, + useImperativeHandle, + forwardRef, + useMemo, + useCallback, +} from 'react'; +import { + type GestureResponderEvent, + type LayoutChangeEvent, + type NativeSyntheticEvent, + type StyleProp, + type TargetedEvent, + Text, + TextInput, + type TextInputFocusEventData, + type TextInputProps, + type TextStyle, + TouchableWithoutFeedback, + View, + type ViewStyle, + Pressable, +} from 'react-native'; +import Animated, { + useAnimatedStyle, + withTiming, + Easing, +} from 'react-native-reanimated'; + +import CompassIcon from '@components/compass_icon'; +import SelectedChip, {USER_CHIP_HEIGHT} from '@components/selected_chip'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import {getLabelPositions} from './utils'; + +const BORDER_DEFAULT_WIDTH = 1; +const BORDER_FOCUSED_WIDTH = 2; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + width: '100%', + }, + errorContainer: { + flexDirection: 'row', + borderColor: 'transparent', // Hack to properly place text in flexbox + borderWidth: 1, + }, + errorIcon: { + color: theme.errorTextColor, + marginRight: 7, + top: 5, + ...typography('Body', 100), + }, + errorText: { + color: theme.errorTextColor, + paddingVertical: 5, + ...typography('Body', 75), + }, + input: { + backgroundColor: 'transparent', + borderWidth: 0, + paddingHorizontal: 0, + paddingTop: 0, + paddingBottom: 0, + height: USER_CHIP_HEIGHT, + flexGrow: 1, + flexShrink: 0, + flexBasis: 'auto', + alignSelf: 'stretch', + }, + label: { + ...typography('Body', 200), + position: 'absolute', + lineHeight: 16, + color: changeOpacity(theme.centerChannelColor, 0.64), + left: 16, + zIndex: 10, + }, + readOnly: { + backgroundColor: changeOpacity(theme.centerChannelBg, 0.16), + }, + smallLabel: { + ...typography('Body', 25), + }, + textInput: { + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'flex-start', + alignContent: 'flex-start', + alignItems: 'flex-start', + textAlignVertical: 'center', + paddingTop: 12, + paddingBottom: 12, + paddingHorizontal: 16, + color: theme.centerChannelColor, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + borderRadius: 4, + borderWidth: BORDER_DEFAULT_WIDTH, + backgroundColor: theme.centerChannelBg, + ...typography('Body', 200), + }, + chipContainer: { + flexGrow: 0, + flexShrink: 1, + flexBasis: 'auto', + alignSelf: 'auto', + }, +})); + +export type Ref = { + blur: () => void; + focus: () => void; + isFocused: () => boolean; +} + +type TextInputPropsFiltered = Omit; + +type Props = TextInputPropsFiltered & { + containerStyle?: StyleProp; + editable?: boolean; + error?: string; + errorIcon?: string; + isKeyboardInput?: boolean; + label: string; + labelTextStyle?: TextStyle; + onBlur?: (event: NativeSyntheticEvent) => void; + onFocus?: (e: NativeSyntheticEvent) => void; + onLayout?: (e: LayoutChangeEvent) => void; + onPress?: (e: GestureResponderEvent) => void; + placeholder?: string; + showErrorIcon?: boolean; + testID?: string; + textInputStyle?: TextStyle; + theme: Theme; + chipsValues?: string[]; + textInputValue: string; + onTextInputChange: TextInputProps['onChangeText']; + onChipRemove: (value: string) => void; + onTextInputSubmitted: () => void; +} + +const FloatingTextChipsInput = forwardRef(({ + textInputValue, + textInputStyle, + onTextInputChange, + onTextInputSubmitted, + chipsValues, + onChipRemove, + theme, + containerStyle, + editable = true, + error, + errorIcon = 'alert-outline', + isKeyboardInput = true, + label = '', + labelTextStyle, + onBlur, + onFocus, + onLayout, + onPress, + placeholder, + showErrorIcon = true, + testID, + ...restProps +}, ref) => { + const [focused, setIsFocused] = useState(false); + const [focusedLabel, setIsFocusLabel] = useState(); + + const inputRef = useRef(null); + + const styles = getStyleSheet(theme); + + const hasValues = textInputValue.length > 0 || (chipsValues?.length ?? 0) > 0; + + const shouldShowError = !focused && error; + + const positions = useMemo(() => getLabelPositions(styles.textInput, styles.label, styles.smallLabel), [styles]); + + // Exposes the blur, focus and isFocused methods to the parent component + useImperativeHandle(ref, () => ({ + blur: () => inputRef.current?.blur(), + focus: () => inputRef.current?.focus(), + isFocused: () => inputRef.current?.isFocused() || false, + }), [inputRef]); + + const onTextInputBlur = useCallback((e: NativeSyntheticEvent) => { + setIsFocusLabel(hasValues); + setIsFocused(false); + + onBlur?.(e); + }, [onBlur, hasValues]); + + const onTextInputFocus = useCallback((e: NativeSyntheticEvent) => { + setIsFocusLabel(true); + setIsFocused(true); + + onFocus?.(e); + }, [onFocus]); + + function handlePressOnContainer() { + if (!focused) { + inputRef?.current?.focus(); + } + } + + function handleTouchableOnPress(event: GestureResponderEvent) { + if (!isKeyboardInput && editable && onPress) { + onPress(event); + } + } + + const textInputContainerStyles = useMemo(() => { + const res: StyleProp = [styles.textInput]; + if (!editable) { + res.push(styles.readOnly); + } + res.push({ + borderWidth: focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH, + minHeight: (USER_CHIP_HEIGHT * 2.5) + ((focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH) * 2), + }); + + if (focused) { + res.push({borderColor: theme.buttonBg}); + } else if (shouldShowError) { + res.push({borderColor: theme.errorTextColor}); + } + + res.push(textInputStyle); + return res; + }, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, editable]); + + const textAnimatedTextStyle = useAnimatedStyle(() => { + const inputText = placeholder || hasValues; + const index = inputText || focusedLabel ? 1 : 0; + + const toValue = positions[index]; + + const size = [styles.textInput.fontSize, styles.smallLabel.fontSize]; + const toSize = size[index] as number; + + let color = styles.label.color; + if (shouldShowError) { + color = theme.errorTextColor; + } else if (focused) { + color = theme.buttonBg; + } + + return { + top: withTiming(toValue, {duration: 100, easing: Easing.linear}), + fontSize: withTiming(toSize, {duration: 100, easing: Easing.linear}), + backgroundColor: focusedLabel || inputText ? theme.centerChannelBg : 'transparent', + paddingHorizontal: focusedLabel || inputText ? 4 : 0, + color, + }; + }); + + return ( + + + + + {label} + + + {chipsValues && chipsValues?.length > 0 && chipsValues.map((chipValue) => ( + + ))} + + + + {Boolean(error) && ( + + {showErrorIcon && errorIcon && + + } + + {error} + + + )} + + + ); +}); + +FloatingTextChipsInput.displayName = 'FloatingTextChipsInput'; +export default FloatingTextChipsInput; diff --git a/app/components/floating_text_chips_input/utils.test.ts b/app/components/floating_text_chips_input/utils.test.ts new file mode 100644 index 000000000..417e9f63d --- /dev/null +++ b/app/components/floating_text_chips_input/utils.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getLabelPositions} from './utils'; + +describe('getLabelPositions', () => { + test('should return correct positions when all styles are provided', () => { + const style = { + paddingTop: 10, + paddingBottom: 10, + height: 50, + fontSize: 14, + padding: 20, + }; + const labelStyle = {fontSize: 15}; + const smallLabelStyle = {fontSize: 11}; + + const result = getLabelPositions(style, labelStyle, smallLabelStyle); + expect(result).toEqual([24.4, -6.5]); + }); + + test('should return correct positions when label and smallLabels styles are missing', () => { + const style = { + paddingTop: 15, + paddingBottom: 15, + height: 50, + fontSize: 14, + padding: 25, + }; + const labelStyle = {}; + const smallLabelStyle = {}; + + const result = getLabelPositions(style, labelStyle, smallLabelStyle); + expect(result).toEqual([23.8, -6.5]); + }); + + test('should return correct positions when all values are empty are provided', () => { + const style = {}; + const labelStyle = {}; + const smallLabelStyle = {}; + + const result = getLabelPositions(style, labelStyle, smallLabelStyle); + expect(result[0]).toBeCloseTo(-1.8); + expect(result[1]).toBeCloseTo(-6.5); + }); + + test('should return correct positions when all values are zero', () => { + const style = { + paddingTop: 0, + paddingBottom: 0, + height: 0, + fontSize: 0, + padding: 0, + }; + const labelStyle = {fontSize: 0}; + const smallLabelStyle = {fontSize: 0}; + + const result = getLabelPositions(style, labelStyle, smallLabelStyle); + expect(result[0]).toBeCloseTo(-1.8); + expect(result[1]).toBeCloseTo(-6.5); + }); +}); diff --git a/app/components/floating_text_chips_input/utils.ts b/app/components/floating_text_chips_input/utils.ts new file mode 100644 index 000000000..77d26e2e9 --- /dev/null +++ b/app/components/floating_text_chips_input/utils.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Platform, type TextStyle} from 'react-native'; + +export const getLabelPositions = (style: TextStyle, labelStyle: TextStyle, smallLabelStyle: TextStyle) => { + const top = style.paddingTop as number || 0; + const bottom = style.paddingBottom as number || 0; + + const height = (style.height as number || (top + bottom) || style.padding as number) || 0; + const textInputFontSize = style.fontSize || 13; + const labelFontSize = labelStyle.fontSize || 16; + const smallLabelFontSize = smallLabelStyle.fontSize || 10; + const fontSizeDiff = textInputFontSize - labelFontSize; + const unfocused = (height * 0.5) + (fontSizeDiff * (Platform.OS === 'android' ? 0.5 : 0.6)); + const focused = -(labelFontSize + smallLabelFontSize) * 0.25; + return [unfocused, focused]; +}; + diff --git a/app/components/selected_chip/index.tsx b/app/components/selected_chip/index.tsx index 0b78e7dc2..f961d5259 100644 --- a/app/components/selected_chip/index.tsx +++ b/app/components/selected_chip/index.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback} from 'react'; -import {Text, TouchableOpacity, useWindowDimensions} from 'react-native'; +import {Text, TouchableOpacity, useWindowDimensions, type StyleProp, type ViewStyle} from 'react-native'; import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; import CompassIcon from '@components/compass_icon'; @@ -17,6 +17,7 @@ type SelectedChipProps = { extra?: React.ReactNode; onRemove: (id: string) => void; testID?: string; + containerStyle?: StyleProp; } export const USER_CHIP_HEIGHT = 32; @@ -54,11 +55,14 @@ export default function SelectedChip({ extra, onRemove, testID, + containerStyle, }: SelectedChipProps) { const theme = useTheme(); const style = getStyleFromTheme(theme); const dimensions = useWindowDimensions(); + const containerStyles = [style.container, containerStyle]; + const onPress = useCallback(() => { onRemove(id); }, [onRemove, id]); @@ -67,7 +71,7 @@ export default function SelectedChip({ {extra} diff --git a/app/screens/settings/notification_mention/mention_settings.test.tsx b/app/screens/settings/notification_mention/mention_settings.test.tsx new file mode 100644 index 000000000..3ba4f42c0 --- /dev/null +++ b/app/screens/settings/notification_mention/mention_settings.test.tsx @@ -0,0 +1,121 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getMentionProps, canSaveSettings, getUniqueKeywordsFromInput, type CanSaveSettings} from './mention_settings'; + +import type UserModel from '@typings/database/models/servers/user'; + +describe('getMentionProps', () => { + test('Should have correct return type when input is empty', () => { + const mentionProps = getMentionProps({notifyProps: {} as UserNotifyProps} as UserModel); + + expect(mentionProps).toEqual({ + mentionKeywords: [], + usernameMention: false, + channel: false, + first_name: false, + comments: '', + notifyProps: {}, + }); + }); + + test('Should have correct return type for when channel, first_name, currentUser.username are provided', () => { + const mentionProps = getMentionProps({ + username: 'testUser', + notifyProps: { + comments: 'any', + channel: 'true', + first_name: 'true', + mention_keys: 'testUser', + } as UserNotifyProps, + } as UserModel); + + expect(mentionProps.mentionKeywords).toEqual([]); + expect(mentionProps.usernameMention).toEqual(true); + expect(mentionProps.channel).toEqual(true); + expect(mentionProps.first_name).toEqual(true); + }); + + test('Should have correct return type for mention_keys input', () => { + const mentionProps = getMentionProps({ + username: 'testUser', + notifyProps: { + mention_keys: 'testUser,testUser2,testKey1,testKey2', + } as UserNotifyProps, + } as UserModel); + + expect(mentionProps.mentionKeywords).toHaveLength(3); + expect(mentionProps.mentionKeywords).toEqual(['testUser2', 'testKey1', 'testKey2']); + }); +}); + +describe('canSaveSettings', () => { + test('Should return true when mentionKeywords have changed', () => { + const canSaveSettingParams = { + mentionKeywords: ['test1', 'test2'], + mentionProps: { + mentionKeywords: ['test1', 'test2', 'test3'], + }, + } as CanSaveSettings; + + expect(canSaveSettings(canSaveSettingParams)).toEqual(true); + }); + + test('Should return false when mentionKeywords have not changed', () => { + const canSaveSettingParams = { + mentionKeywords: ['test1', 'test2'], + mentionProps: { + mentionKeywords: ['test2', 'test1'], + }, + } as CanSaveSettings; + + expect(canSaveSettings(canSaveSettingParams)).toEqual(false); + }); + + test('Should return true when only userName has changed', () => { + const canSaveSettingParams = { + channelMentionOn: true, + replyNotificationType: 'any', + firstNameMentionOn: true, + usernameMentionOn: true, + mentionKeywords: ['test1', 'test2'], + mentionProps: { + channel: true, + comments: 'any' as UserNotifyProps['comments'], + first_name: true, + usernameMention: false, + mentionKeywords: ['test1', 'test2'], + notifyProps: {} as UserNotifyProps, + }, + }; + + expect(canSaveSettings(canSaveSettingParams)).toEqual(true); + }); +}); + +describe('getUniqueKeywordsFromInput', () => { + test('Should return empty if input is empty and keywords are empty', () => { + expect(getUniqueKeywordsFromInput('', [])).toEqual([]); + }); + + test('Should return same keywords if input is empty', () => { + expect(getUniqueKeywordsFromInput('', ['test1', 'test2'])).toEqual(['test1', 'test2']); + }); + + test('Should return same input if keywords are empty', () => { + expect(getUniqueKeywordsFromInput('test1', [])).toEqual(['test1']); + }); + + test('Should filter out commas from input', () => { + expect(getUniqueKeywordsFromInput('tes,,t1,', [])).toEqual(['test1']); + expect(getUniqueKeywordsFromInput(',, ,', ['test1'])).toEqual(['test1']); + }); + + test('Should filter out spaces from input', () => { + expect(getUniqueKeywordsFromInput('t es t 1', [])).toEqual(['test1']); + }); + + test('Should filter out duplicate keywords from input', () => { + expect(getUniqueKeywordsFromInput('te,s t1', ['test1', 'test2'])).toEqual(['test1', 'test2']); + }); +}); diff --git a/app/screens/settings/notification_mention/mention_settings.tsx b/app/screens/settings/notification_mention/mention_settings.tsx index bd951c269..e7a2bc839 100644 --- a/app/screens/settings/notification_mention/mention_settings.tsx +++ b/app/screens/settings/notification_mention/mention_settings.tsx @@ -6,7 +6,7 @@ import {useIntl} from 'react-intl'; import {Text} from 'react-native'; import {updateMe} from '@actions/remote/user'; -import FloatingTextInput from '@components/floating_text_input_label'; +import FloatingTextChipsInput from '@components/floating_text_chips_input'; import SettingBlock from '@components/settings/block'; import SettingOption from '@components/settings/option'; import SettingSeparator from '@components/settings/separator'; @@ -17,6 +17,7 @@ import useBackNavigation from '@hooks/navigate_back'; import {t} from '@i18n'; import {popTopScreen} from '@screens/navigation'; import ReplySettings from '@screens/settings/notification_mention/reply_settings'; +import {areBothStringArraysEqual} from '@utils/helpers'; import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import {getNotificationProps} from '@utils/user'; @@ -29,11 +30,12 @@ const mentionHeaderText = { defaultMessage: 'Keywords that trigger mentions', }; +const COMMA_KEY = ','; + const getStyleSheet = makeStyleSheetFromTheme((theme) => { return { input: { color: theme.centerChannelColor, - height: 150, paddingHorizontal: 15, ...typography('Body', 100, 'Regular'), }, @@ -42,7 +44,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { alignSelf: 'center', paddingHorizontal: 18.5, }, - labelTextStyle: {left: 32}, keywordLabelStyle: { paddingHorizontal: 18.5, marginTop: 4, @@ -52,36 +53,74 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { }; }); -const getMentionProps = (currentUser?: UserModel) => { - const notifyProps = getNotificationProps(currentUser); - const mKeys = (notifyProps.mention_keys || '').split(','); - - const usernameMentionIndex = currentUser ? mKeys.indexOf(currentUser.username) : -1; - if (usernameMentionIndex > -1) { - mKeys.splice(usernameMentionIndex, 1); - } - - return { - mentionKeywords: mKeys.join(','), - usernameMention: usernameMentionIndex > -1, - channel: notifyProps.channel === 'true', - first_name: notifyProps.first_name === 'true', - comments: notifyProps.comments, - notifyProps, - }; -}; - -type MentionSectionProps = { +type Props = { componentId: AvailableScreens; currentUser?: UserModel; isCRTEnabled: boolean; +}; + +export function getMentionProps(currentUser?: UserModel) { + const notifyProps = getNotificationProps(currentUser); + const mentionKeys = notifyProps?.mention_keys ?? ''; + + let mentionKeywords: string[] = []; + let usernameMention = false; + mentionKeys.split(',').forEach((mentionKey) => { + if (currentUser && mentionKey === currentUser.username) { + usernameMention = true; + } else if (mentionKey) { + mentionKeywords = [...mentionKeywords, mentionKey]; + } + }); + + return { + mentionKeywords, + usernameMention, + channel: notifyProps.channel === 'true', + first_name: notifyProps.first_name === 'true', + comments: notifyProps.comments || '', + notifyProps, + }; } -const MentionSettings = ({componentId, currentUser, isCRTEnabled}: MentionSectionProps) => { + +export type CanSaveSettings = { + channelMentionOn: boolean; + replyNotificationType: string; + firstNameMentionOn: boolean; + mentionKeywords: string[]; + usernameMentionOn: boolean; + mentionProps: ReturnType; +} + +export function canSaveSettings({channelMentionOn, replyNotificationType, firstNameMentionOn, mentionKeywords, usernameMentionOn, mentionProps}: CanSaveSettings) { + const channelChanged = channelMentionOn !== mentionProps.channel; + const replyChanged = replyNotificationType !== mentionProps.comments; + const firstNameChanged = firstNameMentionOn !== mentionProps.first_name; + const userNameChanged = usernameMentionOn !== mentionProps.usernameMention; + const mentionKeywordsChanged = !areBothStringArraysEqual(mentionKeywords, mentionProps.mentionKeywords); + + return channelChanged || replyChanged || firstNameChanged || userNameChanged || mentionKeywordsChanged; +} + +export function getUniqueKeywordsFromInput(inputText: string, keywords: string[]) { + // Replace all the spaces and commas + const formattedInputText = inputText.trim().replace(/ |,/g, ''); + + // Check if the keyword is not empty and not already in the list + if (formattedInputText.length > 0 && !keywords.includes(formattedInputText)) { + return [...keywords, formattedInputText]; + } + + return keywords; +} + +const MentionSettings = ({componentId, currentUser, isCRTEnabled}: Props) => { const serverUrl = useServerUrl(); const mentionProps = useMemo(() => getMentionProps(currentUser), []); const notifyProps = mentionProps.notifyProps; const [mentionKeywords, setMentionKeywords] = useState(mentionProps.mentionKeywords); + const [mentionKeywordsInput, setMentionKeywordsInput] = useState(''); const [channelMentionOn, setChannelMentionOn] = useState(mentionProps.channel); const [firstNameMentionOn, setFirstNameMentionOn] = useState(mentionProps.first_name); const [usernameMentionOn, setUsernameMentionOn] = useState(mentionProps.usernameMention); @@ -93,36 +132,32 @@ const MentionSettings = ({componentId, currentUser, isCRTEnabled}: MentionSectio const close = () => popTopScreen(componentId); - const canSaveSettings = useCallback(() => { - const channelChanged = channelMentionOn !== mentionProps.channel; - const replyChanged = replyNotificationType !== mentionProps.comments; - const fNameChanged = firstNameMentionOn !== mentionProps.first_name; - const mnKeysChanged = mentionProps.mentionKeywords !== mentionKeywords; - const userNameChanged = usernameMentionOn !== mentionProps.usernameMention; - - return fNameChanged || userNameChanged || channelChanged || mnKeysChanged || replyChanged; - }, [firstNameMentionOn, channelMentionOn, usernameMentionOn, mentionKeywords, notifyProps, replyNotificationType]); - const saveMention = useCallback(() => { if (!currentUser) { return; } - const canSave = canSaveSettings(); + const canSave = canSaveSettings({ + channelMentionOn, + replyNotificationType, + firstNameMentionOn, + usernameMentionOn, + mentionKeywords, + mentionProps, + }); if (canSave) { - const mention_keys = []; - if (mentionKeywords.length > 0) { - mentionKeywords.split(',').forEach((m) => mention_keys.push(m.replace(/\s/g, ''))); - } - + let mention_keys = []; if (usernameMentionOn) { mention_keys.push(`${currentUser.username}`); } + + mention_keys = [...mention_keys, ...mentionKeywords]; + const notify_props: UserNotifyProps = { ...notifyProps, - first_name: `${firstNameMentionOn}`, - channel: `${channelMentionOn}`, + first_name: firstNameMentionOn ? 'true' : 'false', + channel: channelMentionOn ? 'true' : 'false', mention_keys: mention_keys.join(','), comments: replyNotificationType, }; @@ -131,30 +166,60 @@ const MentionSettings = ({componentId, currentUser, isCRTEnabled}: MentionSectio close(); }, [ - canSaveSettings, channelMentionOn, firstNameMentionOn, + usernameMentionOn, mentionKeywords, notifyProps, + mentionProps, replyNotificationType, serverUrl, + currentUser, ]); - const onToggleFirstName = useCallback(() => { + const handleFirstNameToggle = useCallback(() => { setFirstNameMentionOn((prev) => !prev); }, []); - const onToggleUserName = useCallback(() => { + const handleUsernameToggle = useCallback(() => { setUsernameMentionOn((prev) => !prev); }, []); - const onToggleChannel = useCallback(() => { + const handleChannelToggle = useCallback(() => { setChannelMentionOn((prev) => !prev); }, []); - const onChangeText = useCallback((text: string) => { - setMentionKeywords(text); - }, []); + function appendKeywordsAndClearInput(key: string, list: string[]) { + const keyAppendedToList = getUniqueKeywordsFromInput(key, list); + + setMentionKeywordsInput(''); + requestAnimationFrame(() => { + setMentionKeywords(keyAppendedToList); + }); + } + + /** + * Handler on every key press in the input + */ + const handleMentionKeywordsInputChanged = useCallback((text: string) => { + if (text.includes(COMMA_KEY)) { + appendKeywordsAndClearInput(text, mentionKeywords); + } else { + setMentionKeywordsInput(text); + } + }, [mentionKeywords]); + + /** + * Handler when the user presses the enter key on keyboard + * Takes unsaved keywords from the input and adds them to the list + */ + const handleMentionKeywordEntered = useCallback(() => { + appendKeywordsAndClearInput(mentionKeywordsInput, mentionKeywords); + }, [mentionKeywordsInput, mentionKeywords]); + + const handleMentionKeywordRemoved = useCallback((keyword: string) => { + setMentionKeywords(mentionKeywords.filter((item) => item !== keyword)); + }, [mentionKeywords]); useBackNavigation(saveMention); @@ -168,7 +233,7 @@ const MentionSettings = ({componentId, currentUser, isCRTEnabled}: MentionSectio {Boolean(currentUser?.firstName) && ( <> - ) - } + )} {Boolean(currentUser?.username) && ( - - {intl.formatMessage({id: 'notification_settings.mentions.keywordsLabel', defaultMessage: 'Keywords are not case-sensitive. Separate keywords with commas.'})} + {intl.formatMessage({ + id: 'notification_settings.mentions.keywordsLabel', + defaultMessage: + 'Keywords are not case-sensitive. Separate keywords with commas.', + })} {!isCRTEnabled && ( diff --git a/app/utils/helpers.test.ts b/app/utils/helpers.test.ts new file mode 100644 index 000000000..f328ea0a7 --- /dev/null +++ b/app/utils/helpers.test.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {areBothStringArraysEqual} from '@utils/helpers'; + +describe('areBothStringArraysEqual', () => { + test('Should return false when length of arrays are not equal', () => { + const array1 = ['test1', 'test2']; + const array2 = ['test1']; + + expect(areBothStringArraysEqual(array1, array2)).toEqual(false); + }); + + test('Should return false when arrays are not equal', () => { + const array1 = ['test1', 'test2']; + const array2 = ['test1', 'test2', 'test3']; + + expect(areBothStringArraysEqual(array1, array2)).toEqual(false); + }); + + test('Should return false when either array is empty', () => { + const array1 = ['test1', 'test2']; + const array2: string[] = []; + + expect(areBothStringArraysEqual(array1, array2)).toEqual(false); + }); + + test('Should return true when arrays are equal', () => { + const array1 = ['test1', 'test2']; + const array2 = ['test1', 'test2']; + + expect(areBothStringArraysEqual(array1, array2)).toEqual(true); + }); + + test('Should return true when arrays are equal but in different order', () => { + const array1 = ['test1', 'test2']; + const array2 = ['test2', 'test1']; + + expect(areBothStringArraysEqual(array1, array2)).toEqual(true); + }); + + test('Should return true when both arrays are empty', () => { + const array1: string[] = []; + const array2: string[] = []; + + expect(areBothStringArraysEqual(array1, array2)).toEqual(false); + }); +}); diff --git a/app/utils/helpers.ts b/app/utils/helpers.ts index 17417bd89..a3707b730 100644 --- a/app/utils/helpers.ts +++ b/app/utils/helpers.ts @@ -157,3 +157,19 @@ export function isMainActivity() { android: ShareModule?.getCurrentActivityName() === 'MainActivity', }); } + +export function areBothStringArraysEqual(a: string[], b: string[]) { + if (a.length !== b.length) { + return false; + } + + if (a.length === 0 && b.length === 0) { + return false; + } + + const aSorted = a.sort(); + const bSorted = b.sort(); + const areBothEqual = aSorted.every((value, index) => value === bSorted[index]); + + return areBothEqual; +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index f6d0b6712..fba53110e 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -778,7 +778,7 @@ "notification_settings.mentions_replies": "Mentions and Replies", "notification_settings.mentions..keywordsDescription": "Other words that trigger a mention", "notification_settings.mentions.channelWide": "Channel-wide mentions", - "notification_settings.mentions.keywords": "Keywords", + "notification_settings.mentions.keywords": "Enter other keywords", "notification_settings.mentions.keywords_mention": "Keywords that trigger mentions", "notification_settings.mentions.keywordsLabel": "Keywords are not case-sensitive. Separate keywords with commas.", "notification_settings.mentions.sensitiveName": "Your case sensitive first name",