MM-53479 : Improve design of keyword that trigger notification in mobile (#7458)

This commit is contained in:
M-ZubairAhmed 2024-01-13 06:56:22 +00:00 committed by GitHub
parent 810f4b3d24
commit cc96982d24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 735 additions and 67 deletions

View file

@ -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<TextInputProps, 'value' | 'defaultValue' | 'onChange'>;
type Props = TextInputPropsFiltered & {
containerStyle?: StyleProp<ViewStyle>;
editable?: boolean;
error?: string;
errorIcon?: string;
isKeyboardInput?: boolean;
label: string;
labelTextStyle?: TextStyle;
onBlur?: (event: NativeSyntheticEvent<TargetedEvent>) => void;
onFocus?: (e: NativeSyntheticEvent<TargetedEvent>) => 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<Ref, Props>(({
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<boolean | undefined>();
const inputRef = useRef<TextInput>(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<TextInputFocusEventData>) => {
setIsFocusLabel(hasValues);
setIsFocused(false);
onBlur?.(e);
}, [onBlur, hasValues]);
const onTextInputFocus = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => {
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<TextStyle> = [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 (
<TouchableWithoutFeedback
onPress={handleTouchableOnPress}
onLayout={onLayout}
>
<View style={[styles.container, containerStyle]}>
<Pressable onPress={handlePressOnContainer}>
<Animated.Text
style={[styles.label, labelTextStyle, textAnimatedTextStyle]}
suppressHighlighting={true}
numberOfLines={1}
>
{label}
</Animated.Text>
<View style={textInputContainerStyles}>
{chipsValues && chipsValues?.length > 0 && chipsValues.map((chipValue) => (
<SelectedChip
key={chipValue}
id={chipValue}
text={chipValue}
onRemove={onChipRemove}
containerStyle={styles.chipContainer}
/>
))}
<TextInput
{...restProps}
ref={inputRef}
testID={testID}
placeholder={placeholder}
placeholderTextColor={styles.label.color}
pointerEvents={isKeyboardInput ? 'auto' : 'none'}
underlineColorAndroid='transparent'
editable={isKeyboardInput && editable}
multiline={false}
style={[styles.textInput, styles.input, textInputStyle]}
onFocus={onTextInputFocus}
onBlur={onTextInputBlur}
onChangeText={onTextInputChange}
onSubmitEditing={onTextInputSubmitted}
value={textInputValue}
/>
</View>
</Pressable>
{Boolean(error) && (
<View style={styles.errorContainer}>
{showErrorIcon && errorIcon &&
<CompassIcon
name={errorIcon}
style={styles.errorIcon}
/>
}
<Text
style={styles.errorText}
testID={`${testID}.error`}
>
{error}
</Text>
</View>
)}
</View>
</TouchableWithoutFeedback>
);
});
FloatingTextChipsInput.displayName = 'FloatingTextChipsInput';
export default FloatingTextChipsInput;

View file

@ -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);
});
});

View file

@ -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];
};

View file

@ -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<ViewStyle>;
}
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({
<Animated.View
entering={FadeIn.duration(FADE_DURATION)}
exiting={FadeOut.duration(FADE_DURATION)}
style={style.container}
style={containerStyles}
testID={testID}
>
{extra}

View file

@ -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']);
});
});

View file

@ -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<typeof getMentionProps>;
}
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) && (
<>
<SettingOption
action={onToggleFirstName}
action={handleFirstNameToggle}
description={intl.formatMessage({id: 'notification_settings.mentions.sensitiveName', defaultMessage: 'Your case sensitive first name'})}
label={currentUser!.firstName}
selected={firstNameMentionOn}
@ -177,11 +242,10 @@ const MentionSettings = ({componentId, currentUser, isCRTEnabled}: MentionSectio
/>
<SettingSeparator/>
</>
)
}
)}
{Boolean(currentUser?.username) && (
<SettingOption
action={onToggleUserName}
action={handleUsernameToggle}
description={intl.formatMessage({id: 'notification_settings.mentions.sensitiveUsername', defaultMessage: 'Your non-case sensitive username'})}
label={currentUser!.username}
selected={usernameMentionOn}
@ -191,7 +255,7 @@ const MentionSettings = ({componentId, currentUser, isCRTEnabled}: MentionSectio
)}
<SettingSeparator/>
<SettingOption
action={onToggleChannel}
action={handleChannelToggle}
description={intl.formatMessage({id: 'notification_settings.mentions.channelWide', defaultMessage: 'Channel-wide mentions'})}
label='@channel, @all, @here'
selected={channelMentionOn}
@ -199,32 +263,38 @@ const MentionSettings = ({componentId, currentUser, isCRTEnabled}: MentionSectio
type='toggle'
/>
<SettingSeparator/>
<FloatingTextInput
<FloatingTextChipsInput
allowFontScaling={true}
autoCapitalize='none'
autoCorrect={false}
blurOnSubmit={true}
containerStyle={styles.containerStyle}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
label={intl.formatMessage({id: 'notification_settings.mentions.keywords', defaultMessage: 'Keywords'})}
multiline={true}
onChangeText={onChangeText}
placeholder={intl.formatMessage({id: 'notification_settings.mentions..keywordsDescription', defaultMessage: 'Other words that trigger a mention'})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.4)}
label={intl.formatMessage({
id: 'notification_settings.mentions.keywords',
defaultMessage: 'Enter other keywords',
})}
onTextInputChange={handleMentionKeywordsInputChanged}
onChipRemove={handleMentionKeywordRemoved}
returnKeyType='done'
testID='mention_notification_settings.keywords.input'
textInputStyle={styles.input}
textAlignVertical='top'
textAlignVertical='center'
theme={theme}
underlineColorAndroid='transparent'
value={mentionKeywords}
labelTextStyle={styles.labelTextStyle}
chipsValues={mentionKeywords}
textInputValue={mentionKeywordsInput}
onTextInputSubmitted={handleMentionKeywordEntered}
/>
<Text
style={styles.keywordLabelStyle}
testID='mention_notification_settings.keywords.input.description'
>
{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.',
})}
</Text>
</SettingBlock>
{!isCRTEnabled && (

48
app/utils/helpers.test.ts Normal file
View file

@ -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);
});
});

View file

@ -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;
}

View file

@ -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",