MM-53479 : Improve design of keyword that trigger notification in mobile (#7458)
This commit is contained in:
parent
810f4b3d24
commit
cc96982d24
9 changed files with 735 additions and 67 deletions
328
app/components/floating_text_chips_input/index.tsx
Normal file
328
app/components/floating_text_chips_input/index.tsx
Normal 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;
|
||||
62
app/components/floating_text_chips_input/utils.test.ts
Normal file
62
app/components/floating_text_chips_input/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
app/components/floating_text_chips_input/utils.ts
Normal file
19
app/components/floating_text_chips_input/utils.ts
Normal 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];
|
||||
};
|
||||
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
48
app/utils/helpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue