Add Floating Label Autocomplete Selector (#9119)

* Add Floating Label Autocomplete Selector

* Address feedback

* Revert unintended change

* Move database connection to index file
This commit is contained in:
Daniel Espino García 2025-10-01 13:31:16 +02:00 committed by GitHub
parent ffa40dac11
commit 3efc301da5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1073 additions and 979 deletions

View file

@ -1,7 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {debounce} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {Platform, SectionList, type SectionListRenderItemInfo, type StyleProp, type ViewStyle} from 'react-native';
@ -12,6 +11,7 @@ import AutocompleteSectionHeader from '@components/autocomplete/autocomplete_sec
import SpecialMentionItem from '@components/autocomplete/special_mention_item';
import {AT_MENTION_REGEX, AT_MENTION_SEARCH_REGEX} from '@constants/autocomplete';
import {useServerUrl} from '@context/server';
import {useDebounce} from '@hooks/utils';
import {SECTION_KEY_GROUPS, SECTION_KEY_SPECIAL, emptyGroupList, emptySectionList, emptyUserlList} from './constants';
import {checkSpecialMentions, filterResults, getAllUsers, getMatchTermForAtMention, keyExtractor, makeSections, searchGroups, sortReceivedUsers} from './utils';
@ -66,7 +66,7 @@ const AtMention = ({
const latestSearchAt = useRef(0);
const runSearch = useMemo(() => debounce(async (sUrl: string, term: string, groupMentions: boolean, channelConstrained: boolean, teamConstrained: boolean, tId: string, cId?: string) => {
const runSearch = useDebounce(useCallback(async (sUrl: string, term: string, groupMentions: boolean, channelConstrained: boolean, teamConstrained: boolean, tId: string, cId?: string) => {
const searchAt = Date.now();
latestSearchAt.current = searchAt;
@ -101,7 +101,7 @@ const AtMention = ({
}
setLoading(false);
}, 200), []);
}, [localUsers]), 200);
const teamMembers = useMemo(
() => [...usersInChannel, ...usersOutOfChannel],
@ -144,7 +144,7 @@ const AtMention = ({
setNoResultsTerm(mention);
setSections(emptySectionList);
latestSearchAt.current = Date.now();
}, [value, localCursorPosition, isSearch]);
}, [value, localCursorPosition, isSearch, cursorPosition, updateValue, onShowingChange]);
const renderSpecialMentions = useCallback((item: SpecialMention) => {
return (
@ -206,6 +206,9 @@ const AtMention = ({
if (localCursorPosition !== cursorPosition) {
setLocalCursorPosition(cursorPosition);
}
// We only want to update the local cursor position if the cursor position changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cursorPosition]);
useEffect(() => {
@ -222,6 +225,8 @@ const AtMention = ({
setNoResultsTerm(null);
setLoading(true);
runSearch(serverUrl, matchTerm, useGroupMentions, isChannelConstrained, isTeamConstrained, teamId, channelId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [matchTerm, teamId, useGroupMentions, isChannelConstrained, isTeamConstrained]);
useEffect(() => {
@ -247,6 +252,8 @@ const AtMention = ({
}
setSections(nSections ? newSections : emptySectionList);
onShowingChange(Boolean(nSections));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [!useLocal && usersInChannel, !useLocal && usersOutOfChannel, groups, loading, channelId, useLocal && filteredLocalUsers]);
if (sections.length === 0 || noResultsTerm != null) {

View file

@ -1,7 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {debounce} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {defineMessages} from 'react-intl';
import {Platform, SectionList, type SectionListData, type SectionListRenderItemInfo, type StyleProp, type ViewStyle} from 'react-native';
@ -14,6 +13,7 @@ import {CHANNEL_MENTION_REGEX, CHANNEL_MENTION_SEARCH_REGEX} from '@constants/au
import {useServerUrl} from '@context/server';
import DatabaseManager from '@database/manager';
import useDidUpdate from '@hooks/did_update';
import {useDebounce} from '@hooks/utils';
import {getUserById} from '@queries/servers/user';
import {hasTrailingSpaces} from '@utils/helpers';
import {getUserIdFromChannelName} from '@utils/user';
@ -189,7 +189,7 @@ const ChannelMention = ({
const latestSearchAt = useRef(0);
const runSearch = useMemo(() => debounce(async (sUrl: string, term: string, tId: string) => {
const runSearch = useDebounce(useCallback(async (sUrl: string, term: string, tId: string) => {
const searchAt = Date.now();
latestSearchAt.current = searchAt;
@ -205,7 +205,7 @@ const ChannelMention = ({
setRemoteChannels(channelsToStore.length ? channelsToStore : emptyChannels);
setLoading(false);
}, 200), []);
}, [isSearch]), 200);
const resetState = () => {
latestSearchAt.current = Date.now();
@ -297,6 +297,9 @@ const ChannelMention = ({
if (localCursorPosition !== cursorPosition) {
setLocalCursorPosition(cursorPosition);
}
// We only want to update the local cursor position if the cursor position changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cursorPosition]);
useEffect(() => {
@ -313,6 +316,8 @@ const ChannelMention = ({
setNoResultsTerm(null);
setLoading(true);
runSearch(serverUrl, matchTerm, teamId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [matchTerm, teamId]);
const channels = useMemo(() => {

View file

@ -2,7 +2,6 @@
// See LICENSE.txt for license information.
import Fuse from 'fuse.js';
import {debounce} from 'lodash';
import React, {useCallback, useEffect, useMemo} from 'react';
import {FlatList, Platform, type StyleProp, Text, View, type ViewStyle} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
@ -13,6 +12,7 @@ import Emoji from '@components/emoji';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useDebounce} from '@hooks/utils';
import {getEmojiByName, getEmojis, searchEmojis} from '@utils/emoji/helpers';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -158,7 +158,7 @@ const EmojiSuggestion = ({
updateValue(completedDraft.replace(`::${emoji}: `, `:${emoji}: `));
});
}
}, [value, updateValue, rootId, cursorPosition, shouldDirectlyReact]);
}, [shouldDirectlyReact, value, cursorPosition, customEmojis, updateValue, serverUrl, rootId]);
const renderItem = useCallback(({item}: {item: string}) => {
const completeItemSuggestion = () => completeSuggestion(item);
@ -192,10 +192,17 @@ const EmojiSuggestion = ({
useEffect(() => {
onShowingChange(showingElements);
// We only want to update the showing elements if the showing elements change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showingElements]);
const search = useDebounce(
useCallback(() => searchCustomEmojis(serverUrl, searchTerm), [serverUrl, searchTerm]),
SEARCH_DELAY,
);
useEffect(() => {
const search = debounce(() => searchCustomEmojis(serverUrl, searchTerm), SEARCH_DELAY);
if (searchTerm.length >= MIN_SEARCH_LENGTH) {
search();
}
@ -203,6 +210,9 @@ const EmojiSuggestion = ({
return () => {
search.cancel();
};
// We only want to search if the search term changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchTerm]);
if (!data.length) {

View file

@ -1,8 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {debounce} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {FlatList, Platform, type StyleProp, type ViewStyle} from 'react-native';
@ -10,6 +9,7 @@ import AtMentionItem from '@components/autocomplete/at_mention_item';
import ChannelItem from '@components/channel_item';
import {COMMAND_SUGGESTION_CHANNEL, COMMAND_SUGGESTION_USER} from '@constants/apps';
import {useServerUrl} from '@context/server';
import {useDebounce} from '@hooks/utils';
import {AppCommandParser, type ExtendedAutocompleteSuggestion} from '../app_command_parser/app_command_parser';
import SlashSuggestionItem from '../slash_suggestion_item';
@ -62,19 +62,19 @@ const AppSlashSuggestion = ({
const active = isAppsEnabled && Boolean(dataSource.length);
const mounted = useRef(false);
const fetchAndShowAppCommandSuggestions = useMemo(() => debounce(async (pretext: string, cId: string, tId = '', rId?: string) => {
const updateSuggestions = useCallback((matches: ExtendedAutocompleteSuggestion[]) => {
setDataSource(matches);
onShowingChange(Boolean(matches.length));
}, [onShowingChange]);
const fetchAndShowAppCommandSuggestions = useDebounce(useCallback(async (pretext: string, cId: string, tId = '', rId?: string) => {
appCommandParser.current.setChannelContext(cId, tId, rId);
const suggestions = await appCommandParser.current.getSuggestions(pretext);
if (!mounted.current) {
return;
}
updateSuggestions(suggestions);
}), []);
const updateSuggestions = (matches: ExtendedAutocompleteSuggestion[]) => {
setDataSource(matches);
onShowingChange(Boolean(matches.length));
};
}, [updateSuggestions]), 200);
const completeSuggestion = useCallback((command: string) => {
// We are going to set a double / on iOS to prevent the auto correct from taking over and replacing it
@ -93,7 +93,7 @@ const AppSlashSuggestion = ({
updateValue(completedDraft.replace(`//${command} `, `/${command} `));
});
}
}, [serverUrl, updateValue]);
}, [updateValue]);
const completeIgnoringSuggestion = useCallback((base: string): () => void => {
return () => {
@ -172,6 +172,9 @@ const AppSlashSuggestion = ({
return;
}
fetchAndShowAppCommandSuggestions(value, channelId, currentTeamId, rootId);
// We only want to fetch and show app command suggestions if the value changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
useEffect(() => {

View file

@ -1,8 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {debounce} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {
FlatList,
@ -13,6 +12,7 @@ import {
import {fetchSuggestions} from '@actions/remote/command';
import {useServerUrl} from '@context/server';
import {useDebounce} from '@hooks/utils';
import IntegrationsManager from '@managers/integrations_manager';
import {AppCommandParser} from './app_command_parser/app_command_parser';
@ -91,7 +91,7 @@ const SlashSuggestion = ({
onShowingChange(Boolean(matches.length));
}, [onShowingChange]);
const runFetch = useMemo(() => debounce(async (sUrl: string, term: string, tId: string, cId: string, rId?: string) => {
const runFetch = useDebounce(useCallback(async (sUrl: string, term: string, tId: string, cId: string, rId?: string) => {
try {
const res = await fetchSuggestions(sUrl, term, tId, cId, rId);
if (!mounted.current) {
@ -108,7 +108,7 @@ const SlashSuggestion = ({
} catch {
updateSuggestions(emptySuggestionList);
}
}, 200), [updateSuggestions]);
}, [updateSuggestions]), 200);
const getAppBaseCommandSuggestions = (pretext: string): AutocompleteSuggestion[] => {
appCommandParser.current.setChannelContext(channelId, currentTeamId, rootId);
@ -153,7 +153,7 @@ const SlashSuggestion = ({
updateValue(completedDraft.replace(`//${command} `, `/${command} `));
});
}
}, [updateValue, serverUrl]);
}, [updateValue]);
const renderItem = useCallback(({item}: {item: AutocompleteSuggestion}) => (
<SlashSuggestionItem
@ -197,6 +197,8 @@ const SlashSuggestion = ({
}
runFetch(serverUrl, value, currentTeamId, channelId, rootId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, commands]);
useEffect(() => {

View file

@ -0,0 +1,231 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {type IntlShape, useIntl} from 'react-intl';
import {Text, View, type StyleProp, type TextStyle, type ViewStyle} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Screens, View as ViewConstants} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import DatabaseManager from '@database/manager';
import {usePreventDoubleTap} from '@hooks/utils';
import {getChannelById} from '@queries/servers/channel';
import {getUserById} from '@queries/servers/user';
import {goToScreen} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {typography} from '@utils/typography';
import {displayUsername} from '@utils/user';
import FloatingInputContainer from '../floating_input_container';
type Selection = DialogOption | Channel | UserProfile | DialogOption[] | Channel[] | UserProfile[];
type AutoCompleteSelectorProps = {
dataSource?: string;
disabled?: boolean;
errorText?: string;
getDynamicOptions?: (userInput?: string) => Promise<DialogOption[]>;
label: string;
onSelected?: (value: SelectedDialogOption) => void;
options?: DialogOption[];
placeholder?: string;
selected?: SelectedDialogValue;
teammateNameDisplay: string;
isMultiselect?: boolean;
testID: string;
}
const INPUT_HEIGHT = 48;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
input: {
backgroundColor: changeOpacity(theme.centerChannelBg, 0.9),
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
dropdownPlaceholder: {
color: changeOpacity(theme.centerChannelColor, 0.5),
},
dropdownText: {
...typography('Body', 200),
color: theme.centerChannelColor,
},
disabled: {
opacity: 0.5,
},
};
});
async function getItemName(serverUrl: string, selected: string, teammateNameDisplay: string, intl: IntlShape, dataSource?: string, options?: DialogOption[]): Promise<string> {
if (!selected) {
return '';
}
const database = secureGetFromRecord(DatabaseManager.serverDatabases, serverUrl)?.database;
switch (dataSource) {
case ViewConstants.DATA_SOURCE_USERS: {
if (!database) {
return intl.formatMessage({id: 'channel_loader.someone', defaultMessage: 'Someone'});
}
const user = await getUserById(database, selected);
return displayUsername(user, intl.locale, teammateNameDisplay, true);
}
case ViewConstants.DATA_SOURCE_CHANNELS: {
if (!database) {
return intl.formatMessage({id: 'autocomplete_selector.unknown_channel', defaultMessage: 'Unknown channel'});
}
const channel = await getChannelById(database, selected);
return channel?.displayName || intl.formatMessage({id: 'autocomplete_selector.unknown_channel', defaultMessage: 'Unknown channel'});
}
}
const option = options?.find((opt) => opt.value === selected);
return option?.text || '';
}
function getTextAndValueFromSelectedItem(item: Selection, teammateNameDisplay: string, locale: string, dataSource?: string) {
if (dataSource === ViewConstants.DATA_SOURCE_USERS) {
const user = item as UserProfile;
return {text: displayUsername(user, locale, teammateNameDisplay), value: user.id};
} else if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) {
const channel = item as Channel;
return {text: channel.display_name, value: channel.id};
}
return item as DialogOption;
}
function AutoCompleteSelector({
dataSource,
disabled = false,
errorText,
getDynamicOptions,
label,
onSelected,
options,
placeholder,
selected,
teammateNameDisplay,
isMultiselect = false,
testID,
}: AutoCompleteSelectorProps) {
const intl = useIntl();
const theme = useTheme();
const [itemText, setItemText] = useState('');
const style = getStyleSheet(theme);
const title = placeholder || intl.formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'});
const serverUrl = useServerUrl();
const hasSelected = Boolean(itemText);
const focusedLabel = Boolean(placeholder || hasSelected);
const handleSelect = useCallback((newSelection?: Selection) => {
if (!newSelection) {
return;
}
if (!Array.isArray(newSelection)) {
const selectedOption = getTextAndValueFromSelectedItem(newSelection, teammateNameDisplay, intl.locale, dataSource);
setItemText(selectedOption.text);
if (onSelected) {
onSelected(selectedOption);
}
return;
}
const selectedOptions = newSelection.map((option) => getTextAndValueFromSelectedItem(option, teammateNameDisplay, intl.locale, dataSource));
setItemText(selectedOptions.map((option) => option.text).join(', '));
if (onSelected) {
onSelected(selectedOptions);
}
}, [teammateNameDisplay, intl, dataSource, onSelected]);
const goToSelectorScreen = usePreventDoubleTap(useCallback((() => {
const screen = Screens.INTEGRATION_SELECTOR;
goToScreen(screen, title, {dataSource, handleSelect, options, getDynamicOptions, selected, isMultiselect});
}), [title, dataSource, handleSelect, options, getDynamicOptions, selected, isMultiselect]));
// Handle the text for the default value.
useEffect(() => {
if (!selected) {
return;
}
if (!Array.isArray(selected)) {
getItemName(serverUrl, selected, teammateNameDisplay, intl, dataSource, options).then((res) => setItemText(res));
return;
}
const namePromises = [];
for (const item of selected) {
namePromises.push(getItemName(serverUrl, item, teammateNameDisplay, intl, dataSource, options));
}
Promise.all(namePromises).then((names) => {
setItemText(names.join(', '));
});
}, [dataSource, teammateNameDisplay, intl, options, selected, serverUrl]);
const touchableStyle = useMemo(() => {
const res: StyleProp<ViewStyle> = [{flex: 1}];
if (disabled) {
res.push(style.disabled);
}
return res;
}, [disabled, style.disabled]);
const dropdownTextStyle = useMemo(() => {
const res: StyleProp<TextStyle> = [style.dropdownText];
if (!hasSelected) {
res.push(style.dropdownPlaceholder);
}
return res;
}, [hasSelected, style.dropdownPlaceholder, style.dropdownText]);
return (
<FloatingInputContainer
hasValue={Boolean(itemText)}
defaultHeight={INPUT_HEIGHT}
label={label}
error={errorText}
hideErrorIcon={true}
theme={theme}
focused={false}
focusedLabel={focusedLabel}
editable={!disabled}
testID={testID}
>
<TouchableWithFeedback
disabled={disabled}
onPress={goToSelectorScreen}
style={touchableStyle}
type='opacity'
>
<View style={style.input}>
<Text
numberOfLines={1}
style={dropdownTextStyle}
>
{itemText || placeholder}
</Text>
<CompassIcon
name='chevron-down'
color={changeOpacity(theme.centerChannelColor, 0.5)}
/>
</View>
</TouchableWithFeedback>
</FloatingInputContainer>
);
}
export default AutoCompleteSelector;

View file

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import AutoCompleteSelector from './floating_autocomplete_selector';
import type {WithDatabaseArgs} from '@typings/database/database';
const withTeammateNameDisplay = withObservables([], ({database}: WithDatabaseArgs) => {
return {
teammateNameDisplay: observeTeammateNameDisplay(database),
};
});
export default withDatabase(withTeammateNameDisplay(AutoCompleteSelector));

View file

@ -0,0 +1,237 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {
useMemo,
useCallback,
} from 'react';
import {
type LayoutChangeEvent,
type StyleProp,
Text,
TouchableWithoutFeedback,
View,
type ViewStyle,
Pressable,
} from 'react-native';
import Animated, {
useAnimatedStyle,
withTiming,
Easing,
} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
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%',
},
helpText: {
color: changeOpacity(theme.centerChannelColor, 0.5),
paddingVertical: 5,
...typography('Body', 75),
},
errorContainer: {
flexDirection: 'row',
// Hack to properly place text in flexbox
borderColor: 'transparent',
borderWidth: 1,
},
errorIcon: {
color: theme.errorTextColor,
marginRight: 7,
top: 5,
...typography('Body', 100),
},
errorText: {
color: theme.errorTextColor,
paddingVertical: 5,
...typography('Body', 75),
},
label: {
position: 'absolute',
color: changeOpacity(theme.centerChannelColor, 0.64),
left: 16,
zIndex: 10,
maxWidth: 315,
},
bigLabel: {
...typography('Body', 200),
},
smallLabel: {
...typography('Body', 25),
},
readOnly: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.16),
},
textInput: {
flexDirection: 'row',
paddingTop: 12,
paddingBottom: 12,
paddingHorizontal: 16,
color: theme.centerChannelColor,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
borderRadius: 4,
borderWidth: BORDER_DEFAULT_WIDTH,
backgroundColor: theme.centerChannelBg,
gap: 8,
},
}));
type Props = {
children: React.ReactNode;
hasValue: boolean;
defaultHeight: number;
canGrow?: boolean;
onLayout?: (e: LayoutChangeEvent) => void;
label: string;
error?: string;
hideErrorIcon?: boolean;
theme: Theme;
focus?: () => void;
focused: boolean;
focusedLabel: boolean;
editable: boolean;
wrapChildren?: boolean;
helpText?: string;
testID: string;
}
const FloatingInputContainer = ({
children,
hasValue,
defaultHeight,
canGrow = false,
onLayout,
label,
error,
hideErrorIcon = false,
theme,
focus,
focused,
focusedLabel,
editable,
wrapChildren = false,
helpText,
testID,
}: Props) => {
const styles = getStyleSheet(theme);
const positions = useMemo(() => getLabelPositions(styles.textInput, styles.label, styles.smallLabel), [styles]);
const errorIcon = 'alert-outline';
const shouldShowError = !focused && error;
const handlePressOnContainer = useCallback(() => {
if (!focused) {
focus?.();
}
}, [focus, focused]);
const combinedTextInputContainerStyle = useMemo(() => {
const res: StyleProp<ViewStyle> = [styles.textInput];
if (!editable) {
res.push(styles.readOnly);
}
res.push({
borderWidth: focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH,
});
const height = defaultHeight + ((focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH) * 2);
if (canGrow) {
res.push({
minHeight: height,
});
} else {
res.push({
height,
});
}
if (focused) {
res.push({borderColor: theme.buttonBg});
} else if (shouldShowError) {
res.push({borderColor: theme.errorTextColor});
}
if (wrapChildren) {
res.push({flexWrap: 'wrap'});
}
return res;
}, [styles.textInput, styles.readOnly, editable, focusedLabel, defaultHeight, canGrow, focused, shouldShowError, wrapChildren, theme.buttonBg, theme.errorTextColor]);
const textAnimatedTextStyle = useAnimatedStyle(() => {
const inputText = hasValue;
const index = inputText || focusedLabel ? 1 : 0;
const toValue = positions[index];
const size = [styles.bigLabel.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 {
...(index === 1 ? styles.smallLabel : styles.bigLabel),
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
onLayout={onLayout}
>
<View style={styles.container}>
<Pressable onPress={handlePressOnContainer}>
<Animated.Text
style={[styles.label, textAnimatedTextStyle]}
suppressHighlighting={true}
numberOfLines={1}
>
{label}
</Animated.Text>
<View style={combinedTextInputContainerStyle}>
{children}
</View>
</Pressable>
{Boolean(error) && (
<View style={styles.errorContainer}>
{!hideErrorIcon && errorIcon &&
<CompassIcon
name={errorIcon}
style={styles.errorIcon}
/>
}
<Text
style={styles.errorText}
testID={`${testID}.error`}
>
{error}
</Text>
</View>
)}
{Boolean(helpText) && (
<Text style={styles.helpText}>
{helpText}
</Text>
)}
</View>
</TouchableWithoutFeedback>
);
};
export default FloatingInputContainer;

View file

@ -0,0 +1,146 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {
useState,
useRef,
useImperativeHandle,
forwardRef,
useCallback,
} from 'react';
import {
TextInput,
type TextInputProps,
} from 'react-native';
import {CHIP_HEIGHT} from '@components/chips/constants';
import SelectedChip from '@components/chips/selected_chip';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import FloatingInputContainer from './floating_input_container';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
input: {
paddingHorizontal: 15,
paddingVertical: 0,
flexGrow: 1,
flexShrink: 0,
alignSelf: 'stretch',
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-start',
alignContent: 'flex-start',
alignItems: 'flex-start',
textAlignVertical: 'top',
color: theme.centerChannelColor,
...typography('Body', 100),
},
}));
type Ref = {
blur: () => void;
focus: () => void;
isFocused: () => boolean;
}
type Props = {
label: string;
testID?: string;
theme: Theme;
chipsValues?: string[];
textInputValue: string;
onTextInputChange: TextInputProps['onChangeText'];
onChipRemove: (value: string) => void;
onTextInputSubmitted: () => void;
blurOnSubmit?: TextInputProps['blurOnSubmit'];
returnKeyType?: TextInputProps['returnKeyType'];
}
const FloatingTextChipsInput = forwardRef<Ref, Props>(({
textInputValue,
onTextInputChange,
onTextInputSubmitted,
chipsValues,
onChipRemove,
theme,
label = '',
testID,
...textInputProps
}, ref) => {
const hasValues = textInputValue.length > 0 || (chipsValues?.length ?? 0) > 0;
const [focused, setIsFocused] = useState(false);
const focusedLabel = focused || hasValues;
const inputRef = useRef<TextInput>(null);
const styles = getStyleSheet(theme);
// 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(() => {
setIsFocused(false);
}, []);
const onTextInputFocus = useCallback(() => {
setIsFocused(true);
}, []);
const focus = useCallback(() => {
inputRef.current?.focus();
}, []);
const height = CHIP_HEIGHT * 2.5;
return (
<FloatingInputContainer
hasValue={hasValues}
defaultHeight={height}
canGrow={true}
label={label}
theme={theme}
focus={focus}
focused={focused}
focusedLabel={focusedLabel}
editable={true}
testID={testID || 'floating-text-chips-input'}
wrapChildren={true}
>
{chipsValues && chipsValues?.length > 0 && chipsValues.map((chipValue) => (
<SelectedChip
key={chipValue}
id={chipValue}
text={chipValue}
testID={`${testID}.${chipValue}`}
onRemove={onChipRemove}
/>
))}
<TextInput
{...textInputProps}
ref={inputRef}
testID={testID}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.64)}
underlineColorAndroid='transparent'
editable={true}
multiline={false}
style={styles.input}
onFocus={onTextInputFocus}
onBlur={onTextInputBlur}
onChangeText={onTextInputChange}
onSubmitEditing={onTextInputSubmitted}
value={textInputValue}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
textAlignVertical='top'
/>
</FloatingInputContainer>
);
});
FloatingTextChipsInput.displayName = 'FloatingTextChipsInput';
export default FloatingTextChipsInput;

View file

@ -0,0 +1,170 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Note: This file has been adapted from the library https://github.com/csath/react-native-reanimated-text-input
import React, {useState, useRef, useImperativeHandle, forwardRef, useMemo, useCallback} from 'react';
import {type LayoutChangeEvent, type NativeSyntheticEvent, type StyleProp, type TargetedEvent, TextInput, type TextInputFocusEventData, type TextInputProps, type TextStyle} from 'react-native';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import FloatingInputContainer from './floating_input_container';
import {onExecution} from './utils';
const DEFAULT_INPUT_HEIGHT = 48;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
input: {
backgroundColor: 'transparent',
borderWidth: 0,
flex: 1,
paddingHorizontal: 0,
paddingTop: 0,
paddingBottom: 0,
flexDirection: 'row',
fontFamily: 'OpenSans',
fontSize: 16,
color: theme.centerChannelColor,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
borderRadius: 4,
},
}));
export type FloatingTextInputRef = {
blur: () => void;
focus: () => void;
isFocused: () => boolean;
}
type FloatingTextInputProps = /*TextInputProps &*/ {
editable?: boolean;
endAdornment?: React.ReactNode;
error?: string;
errorIcon?: string;
label: string;
multiline?: boolean;
multilineInputHeight?: number;
onBlur?: (event: NativeSyntheticEvent<TargetedEvent>) => void;
onFocus?: (e: NativeSyntheticEvent<TargetedEvent>) => void;
onLayout?: (e: LayoutChangeEvent) => void;
placeholder?: string;
hideErrorIcon?: boolean;
testID?: string;
theme: Theme;
value?: string;
rawInput?: boolean;
onChangeText?: TextInputProps['onChangeText'];
defaultValue?: TextInputProps['defaultValue'];
autoFocus?: TextInputProps['autoFocus'];
enablesReturnKeyAutomatically?: TextInputProps['enablesReturnKeyAutomatically'];
keyboardType?: TextInputProps['keyboardType'];
onSubmitEditing?: TextInputProps['onSubmitEditing'];
returnKeyType?: TextInputProps['returnKeyType'];
secureTextEntry?: TextInputProps['secureTextEntry'];
blurOnSubmit?: TextInputProps['blurOnSubmit'];
autoComplete?: TextInputProps['autoComplete'];
disableFullscreenUI?: TextInputProps['disableFullscreenUI'];
maxLength?: TextInputProps['maxLength'];
}
const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProps>(({
editable = true,
error,
endAdornment,
label = '',
multiline,
multilineInputHeight,
onBlur,
onFocus,
onLayout,
placeholder,
hideErrorIcon = false,
testID,
theme,
value,
rawInput = false,
...textInputProps
}: FloatingTextInputProps, ref) => {
const [focused, setIsFocused] = useState(false);
const focusedLabel = Boolean(focused || Boolean(value) || placeholder);
const inputRef = useRef<TextInput>(null);
const styles = getStyleSheet(theme);
useImperativeHandle(ref, () => ({
blur: () => inputRef.current?.blur(),
focus: () => inputRef.current?.focus(),
isFocused: () => inputRef.current?.isFocused() || false,
}), [inputRef]);
const onTextInputBlur = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => onExecution(e,
() => {
setIsFocused(false);
},
onBlur,
), [onBlur]);
const onTextInputFocus = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => onExecution(e,
() => {
setIsFocused(true);
},
onFocus,
), [onFocus]);
const defaultHeight = multiline ? multilineInputHeight || 100 : DEFAULT_INPUT_HEIGHT;
const combinedTextInputStyle = useMemo(() => {
const res: StyleProp<TextStyle> = [styles.input];
if (multiline) {
const height = multilineInputHeight ? multilineInputHeight - 20 : 80;
res.push({height, textAlignVertical: 'top'});
}
return res;
}, [styles, multiline, multilineInputHeight]);
const focus = useCallback(() => {
inputRef.current?.focus();
}, []);
return (
<FloatingInputContainer
hasValue={Boolean(value)}
defaultHeight={defaultHeight}
onLayout={onLayout}
label={label}
error={error}
hideErrorIcon={hideErrorIcon}
theme={theme}
focus={focus}
focused={focused}
focusedLabel={focusedLabel}
editable={editable}
testID={testID || 'floating-text-input-label'}
>
<TextInput
{...textInputProps}
editable={editable}
style={combinedTextInputStyle}
placeholder={placeholder}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.64)}
multiline={multiline}
textAlignVertical='top'
value={value}
onFocus={onTextInputFocus}
onBlur={onTextInputBlur}
ref={inputRef}
underlineColorAndroid='transparent'
testID={testID}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
autoCorrect={!rawInput}
autoCapitalize={rawInput ? 'none' : undefined}
/>
{endAdornment}
</FloatingInputContainer>
);
});
FloatingTextInput.displayName = 'FloatingTextInput';
export default FloatingTextInput;

View file

@ -1,330 +0,0 @@
// 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 {CHIP_HEIGHT} from '@components/chips/constants';
import SelectedChip from '@components/chips/selected_chip';
import CompassIcon from '@components/compass_icon';
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: 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: (CHIP_HEIGHT * 2.5) + ((focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH) * 2),
gap: 8,
});
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 as StyleProp<ViewStyle>}>
{chipsValues && chipsValues?.length > 0 && chipsValues.map((chipValue) => (
<SelectedChip
key={chipValue}
id={chipValue}
text={chipValue}
testID={`${testID}.${chipValue}`}
onRemove={onChipRemove}
/>
))}
<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

@ -1,19 +0,0 @@
// 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

@ -1,301 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Note: This file has been adapted from the library https://github.com/csath/react-native-reanimated-text-input
import {debounce} from 'lodash';
import React, {useState, useEffect, 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} from 'react-native';
import Animated, {useAnimatedStyle, withTiming, Easing} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {getLabelPositions, onExecution} from './utils';
const DEFAULT_INPUT_HEIGHT = 48;
const BORDER_DEFAULT_WIDTH = 1;
const BORDER_FOCUSED_WIDTH = 2;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
width: '100%',
},
errorContainer: {
flexDirection: 'row',
// Hack to properly place text in flexbox
borderColor: 'transparent',
borderWidth: 1,
},
errorIcon: {
color: theme.errorTextColor,
fontSize: 14,
marginRight: 7,
top: 5,
},
errorText: {
color: theme.errorTextColor,
fontFamily: 'OpenSans',
fontSize: 12,
lineHeight: 16,
paddingVertical: 5,
},
input: {
backgroundColor: 'transparent',
borderWidth: 0,
flex: 1,
paddingHorizontal: 0,
paddingTop: 0,
paddingBottom: 0,
},
label: {
position: 'absolute',
color: changeOpacity(theme.centerChannelColor, 0.64),
left: 16,
fontFamily: 'OpenSans',
fontSize: 16,
zIndex: 10,
maxWidth: 315,
},
readOnly: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.16),
},
smallLabel: {
fontSize: 10,
},
textInput: {
flexDirection: 'row',
fontFamily: 'OpenSans',
fontSize: 16,
paddingTop: 12,
paddingBottom: 12,
paddingHorizontal: 16,
color: theme.centerChannelColor,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
borderRadius: 4,
borderWidth: BORDER_DEFAULT_WIDTH,
backgroundColor: theme.centerChannelBg,
},
}));
export type FloatingTextInputRef = {
blur: () => void;
focus: () => void;
isFocused: () => boolean;
}
type FloatingTextInputProps = TextInputProps & {
containerStyle?: ViewStyle;
editable?: boolean;
endAdornment?: React.ReactNode;
error?: string;
errorIcon?: string;
isKeyboardInput?: boolean;
label: string;
labelTextStyle?: TextStyle;
multiline?: boolean;
multilineInputHeight?: number;
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;
value?: string;
}
const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProps>(({
containerStyle,
editable = true,
error,
errorIcon = 'alert-outline',
endAdornment,
isKeyboardInput = true,
label = '',
labelTextStyle,
multiline,
multilineInputHeight,
onBlur,
onFocus,
onLayout,
onPress,
placeholder,
showErrorIcon = true,
testID,
textInputStyle,
theme,
value,
...props
}: FloatingTextInputProps, ref) => {
const [focused, setIsFocused] = useState(false);
const [focusedLabel, setIsFocusLabel] = useState<boolean | undefined>();
const inputRef = useRef<TextInput>(null);
const debouncedOnFocusTextInput = debounce(setIsFocusLabel, 500, {leading: true, trailing: false});
const styles = getStyleSheet(theme);
const positions = useMemo(() => getLabelPositions(styles.textInput, styles.label, styles.smallLabel), [styles]);
const size = useMemo(() => [styles.textInput.fontSize, styles.smallLabel.fontSize], [styles]);
const shouldShowError = (!focused && error);
let color = styles.label.color;
if (shouldShowError) {
color = theme.errorTextColor;
} else if (focused) {
color = theme.buttonBg;
}
useImperativeHandle(ref, () => ({
blur: () => inputRef.current?.blur(),
focus: () => inputRef.current?.focus(),
isFocused: () => inputRef.current?.isFocused() || false,
}), [inputRef]);
useEffect(
() => {
if (!focusedLabel && (value || props.defaultValue)) {
debouncedOnFocusTextInput(true);
}
},
[value, props.defaultValue],
);
const onTextInputBlur = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => onExecution(e,
() => {
setIsFocusLabel(Boolean(value));
setIsFocused(false);
},
onBlur,
), [onBlur]);
const onTextInputFocus = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => onExecution(e,
() => {
setIsFocusLabel(true);
setIsFocused(true);
},
onFocus,
), [onFocus]);
const onAnimatedTextPress = useCallback(() => {
return focused ? null : inputRef?.current?.focus();
}, [focused]);
const onPressAction = !isKeyboardInput && editable && onPress ? onPress : undefined;
const combinedContainerStyle = useMemo(() => {
return [styles.container, containerStyle];
}, [styles, containerStyle]);
const combinedTextInputContainerStyle = useMemo(() => {
const res: StyleProp<TextStyle> = [styles.textInput];
if (!editable) {
res.push(styles.readOnly);
}
res.push({
borderWidth: focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH,
height: DEFAULT_INPUT_HEIGHT + ((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);
if (multiline) {
const height = multilineInputHeight || 100;
res.push({height, textAlignVertical: 'top'});
}
return res;
}, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, multiline, multilineInputHeight, editable]);
const combinedTextInputStyle = useMemo(() => {
const res: StyleProp<TextStyle> = [styles.textInput, styles.input, textInputStyle];
if (multiline) {
const height = multilineInputHeight ? multilineInputHeight - 20 : 80;
res.push({height, textAlignVertical: 'top'});
}
return res;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, multiline, multilineInputHeight, editable]);
const textAnimatedTextStyle = useAnimatedStyle(() => {
const inputText = placeholder || value || props.defaultValue;
const index = inputText || focusedLabel ? 1 : 0;
const toValue = positions[index];
const toSize = size[index];
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={onPressAction}
onLayout={onLayout}
>
<View style={combinedContainerStyle}>
<Animated.Text
onPress={onAnimatedTextPress}
style={[styles.label, labelTextStyle, textAnimatedTextStyle]}
suppressHighlighting={true}
numberOfLines={1}
>
{label}
</Animated.Text>
<View style={combinedTextInputContainerStyle as StyleProp<ViewStyle>}>
<TextInput
{...props}
editable={isKeyboardInput && editable}
style={combinedTextInputStyle}
placeholder={placeholder}
placeholderTextColor={styles.label.color}
multiline={multiline}
value={value}
pointerEvents={isKeyboardInput ? 'auto' : 'none'}
onFocus={onTextInputFocus}
onBlur={onTextInputBlur}
ref={inputRef}
underlineColorAndroid='transparent'
testID={testID}
/>
{endAdornment}
</View>
{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>
);
});
FloatingTextInput.displayName = 'FloatingTextInput';
export default FloatingTextInput;

View file

@ -5,8 +5,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import UserList from '@components/user_list';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {debounce} from '@helpers/api/general';
import {useDebounce} from '@hooks/utils';
import {filterProfilesMatchingTerm} from '@utils/user';
import type {AvailableScreens} from '@typings/screens/navigation';
@ -39,8 +38,6 @@ export default function ServerUserList({
location,
customSection,
}: Props) {
const serverUrl = useServerUrl();
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
const next = useRef(true);
const page = useRef(-1);
@ -70,19 +67,19 @@ export default function ServerUserList({
}
};
const getProfiles = useCallback(debounce(() => {
const getProfiles = useDebounce(useCallback(() => {
if (next.current && !loading && !term && mounted.current) {
setLoading(true);
fetchFunction(page.current + 1).then(loadedProfiles);
}
}, 100), [loading, isSearch, serverUrl]);
}, [loading, term, fetchFunction]), 100);
const searchUsers = useCallback(async (searchTerm: string) => {
setLoading(true);
const data = await searchFunction(searchTerm);
setSearchResults(data);
setLoading(false);
}, [serverUrl, searchFunction]);
}, [searchFunction]);
useEffect(() => {
if (term) {
@ -96,6 +93,9 @@ export default function ServerUserList({
} else {
setSearchResults([]);
}
// We only want to run the search when the term changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [term]);
useEffect(() => {
@ -104,6 +104,9 @@ export default function ServerUserList({
return () => {
mounted.current = false;
};
// We only want to get the profiles on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const data = useMemo(() => {
@ -116,7 +119,7 @@ export default function ServerUserList({
return [...exactMatches, ...results];
}
return profiles;
}, [term, isSearch, isSearch && searchResults, profiles]);
}, [isSearch, profiles, createFilter, term, searchResults]);
return (
<UserList

View file

@ -3,7 +3,7 @@
import {useRef, useCallback} from 'react';
import {type FloatingTextInputRef} from '@components/floating_text_input_label';
import {type FloatingTextInputRef} from '@components/floating_input/floating_text_input_label';
const useFieldRefs = (): [(key: string) => FloatingTextInputRef | undefined, (key: string) => (providedRef: FloatingTextInputRef) => () => void] => {
const allRefs = useRef<Map<string, FloatingTextInputRef>>();

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useRef} from 'react';
import {useCallback, useMemo, useRef} from 'react';
const DELAY = 750;
@ -17,3 +17,22 @@ export const usePreventDoubleTap = <T extends Function>(callback: T) => {
callback(...args);
}, [callback]);
};
export const useDebounce = <T extends Function>(callback: T, delay: number) => {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const cancel = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}, []);
const execute = useCallback((...args: unknown[]) => {
cancel();
timeoutRef.current = setTimeout(() => callback(...args), delay);
}, [callback, delay, cancel]);
return useMemo(() => {
return Object.assign(execute, {cancel});
}, [execute, cancel]);
};

View file

@ -40,7 +40,7 @@ describe('EditCommand', () => {
function getBaseProps(): ComponentProps<typeof EditCommand> {
return {
componentId: 'EditCommand' as const,
componentId: 'PlaybookEditCommand',
savedCommand: '/test command',
updateCommand: jest.fn(),
channelId: 'channel-123',

View file

@ -4,7 +4,7 @@
import React, {type ComponentProps} from 'react';
import Autocomplete from '@components/autocomplete';
import FloatingTextInput from '@components/floating_text_input_label';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import DatabaseManager from '@database/manager';
import {renderWithEverything} from '@test/intl-test-helper';
@ -12,7 +12,7 @@ import EditCommandForm from './edit_command_form';
import type {Database} from '@nozbe/watermelondb';
jest.mock('@components/floating_text_input_label', () => ({
jest.mock('@components/floating_input/floating_text_input_label', () => ({
__esModule: true,
default: jest.fn(),
}));
@ -57,11 +57,9 @@ describe('EditCommandForm', () => {
expect(floatingTextInput).toBeTruthy();
expect(floatingTextInput).toHaveProp('value', props.command);
expect(floatingTextInput).toHaveProp('onChangeText', props.onCommandChange);
expect(floatingTextInput).toHaveProp('autoCorrect', false);
expect(floatingTextInput).toHaveProp('autoCapitalize', 'none');
expect(floatingTextInput).toHaveProp('rawInput', true);
expect(floatingTextInput).toHaveProp('disableFullscreenUI', true);
expect(floatingTextInput).toHaveProp('showErrorIcon', false);
expect(floatingTextInput).toHaveProp('spellCheck', false);
expect(floatingTextInput).toHaveProp('hideErrorIcon', true);
expect(floatingTextInput).toHaveProp('disableFullscreenUI', true);
expect(floatingTextInput).toHaveProp('autoFocus', true);

View file

@ -11,13 +11,10 @@ import {
import {SafeAreaView, type Edges} from 'react-native-safe-area-context';
import Autocomplete from '@components/autocomplete';
import FloatingTextInput from '@components/floating_text_input_label';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import {useTheme} from '@context/theme';
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
import {useKeyboardOverlap} from '@hooks/device';
import {
getKeyboardAppearanceFromTheme,
} from '@utils/theme';
const BOTTOM_AUTOCOMPLETE_SEPARATION = 4;
const LIST_PADDING = 32;
@ -92,15 +89,12 @@ export default function EditCommandForm({
>
<View style={styles.mainView}>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
rawInput={true}
disableFullscreenUI={true}
label={labelCommand}
placeholder={placeholderCommand}
onChangeText={onCommandChange}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
showErrorIcon={false}
spellCheck={false}
hideErrorIcon={true}
testID='playbooks.edit_command.input'
value={command}
theme={theme}

View file

@ -11,9 +11,9 @@ import {PER_PAGE_DEFAULT} from '@client/rest/constants';
import PostList from '@components/post_list';
import {Events, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {debounce} from '@helpers/api/general';
import {useAppState, useIsTablet} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {useDebounce} from '@hooks/utils';
import EphemeralStore from '@store/ephemeral_store';
import Intro from './intro';
@ -49,7 +49,7 @@ const ChannelPostList = ({
const [fetchingPosts, setFetchingPosts] = useState(EphemeralStore.isLoadingMessagesForChannel(serverUrl, channelId));
const oldPostsCount = useRef<number>(posts.length);
const onEndReached = useCallback(debounce(async () => {
const onEndReached = useDebounce(useCallback(async () => {
if (!fetchingPosts && canLoadPostsBefore.current && posts.length) {
const lastPost = posts[posts.length - 1];
const result = await fetchPostsBefore(serverUrl, channelId, lastPost?.id || '');
@ -58,7 +58,7 @@ const ChannelPostList = ({
canLoadPostsBefore.current = (result.posts?.length ?? 0) > 0;
}
}
}, 500), [fetchingPosts, serverUrl, channelId, posts]);
}, [fetchingPosts, serverUrl, channelId, posts]), 500);
useDidUpdate(() => {
setFetchingPosts(EphemeralStore.isLoadingMessagesForChannel(serverUrl, channelId));
@ -82,6 +82,9 @@ const ChannelPostList = ({
canLoadPost.current = false;
fetchPosts(serverUrl, channelId);
}
// We only want to run this when the number of posts changes or we stop fetching posts
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchingPosts, posts]);
useDidUpdate(() => {
@ -104,6 +107,9 @@ const ChannelPostList = ({
return () => {
unsetActiveChannelOnServer(serverUrl);
};
// We only want to run this on unmount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const intro = (<Intro channelId={channelId}/>);

View file

@ -5,15 +5,15 @@ import React, {useCallback, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {Platform, View} from 'react-native';
import FloatingTextInput from '@components/floating_text_input_label';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import FormattedText from '@components/formatted_text';
import Loading from '@components/loading';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import {useIsTablet} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {useDebounce} from '@hooks/utils';
import {fetchOpenGraph} from '@utils/opengraph';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {getUrlAfterRedirect} from '@utils/url';
@ -54,7 +54,7 @@ const BookmarkLink = ({disabled, initialUrl = '', resetBookmark, setBookmark}: P
const subContainerStyle = useMemo(() => [styles.viewContainer, {paddingHorizontal: isTablet ? 42 : 0}], [isTablet, styles]);
const descContainer = useMemo(() => [styles.description, {paddingHorizontal: isTablet ? 42 : 0}], [isTablet, styles]);
const validateAndFetchOG = useCallback(debounce(async (text: string) => {
const validateAndFetchOG = useDebounce(useCallback((async (text: string) => {
setLoading(true);
let link = await getUrlAfterRedirect(text, false);
@ -75,7 +75,7 @@ const BookmarkLink = ({disabled, initialUrl = '', resetBookmark, setBookmark}: P
defaultMessage: 'Please enter a valid link',
}));
setLoading(false);
}, 500), [intl]);
}), [intl, setBookmark]), 500);
const onChangeText = useCallback((text: string) => {
resetBookmark();
@ -87,27 +87,24 @@ const BookmarkLink = ({disabled, initialUrl = '', resetBookmark, setBookmark}: P
if (url) {
validateAndFetchOG(url);
}
}, [url, error]);
}, [url, validateAndFetchOG]);
useDidUpdate(debounce(() => {
onSubmitEditing();
}, 300), [onSubmitEditing]);
const debouncedOnSubmitEditing = useDebounce(onSubmitEditing, 300);
useDidUpdate(debouncedOnSubmitEditing, [debouncedOnSubmitEditing]);
return (
<View style={subContainerStyle}>
<FloatingTextInput
autoCapitalize={'none'}
autoCorrect={false}
rawInput={true}
disableFullscreenUI={true}
editable={!disabled}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
keyboardType={keyboard}
returnKeyType='go'
label={intl.formatMessage({id: 'channel_bookmark_add.link', defaultMessage: 'Link'})}
onChangeText={onChangeText}
theme={theme}
error={error}
showErrorIcon={true}
value={url}
onSubmitEditing={onSubmitEditing}
endAdornment={loading &&

View file

@ -4,10 +4,9 @@
import React from 'react';
import {useIntl} from 'react-intl';
import FloatingTextInput from '@components/floating_text_input_label';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import {Channel} from '@constants';
import {useTheme} from '@context/theme';
import {getKeyboardAppearanceFromTheme} from '@utils/theme';
type Props = {
error?: string;
@ -23,18 +22,13 @@ export const ChannelNameInput = ({error, onChange}: Props) => {
return (
<FloatingTextInput
autoCorrect={false}
autoCapitalize='none'
blurOnSubmit={false}
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
label={labelDisplayName}
placeholder={placeholder}
maxLength={Channel.MAX_CHANNEL_NAME_LENGTH}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
returnKeyType='next'
showErrorIcon={true}
spellCheck={false}
testID='gonvert_gm_to_channel.channel_display_name.input'
theme={theme}
error={error}

View file

@ -1,14 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {debounce} from 'lodash';
import React, {useCallback, useMemo, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import SearchBar from '@components/search';
import TeamList from '@components/team_list';
import {useTheme} from '@context/theme';
import {usePreventDoubleTap} from '@hooks/utils';
import {useDebounce, usePreventDoubleTap} from '@hooks/utils';
import SecurityManager from '@managers/security_manager';
import {popTopScreen} from '@screens/navigation';
import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme';
@ -35,18 +34,18 @@ const TeamSelectorList = ({componentId, teams, selectTeam}: Props) => {
const [filteredTeams, setFilteredTeam] = useState(teams);
const color = useMemo(() => changeOpacity(theme.centerChannelColor, 0.72), [theme]);
const handleOnChangeSearchText = useCallback(debounce((searchTerm: string) => {
const handleOnChangeSearchText = useDebounce(useCallback((searchTerm: string) => {
if (searchTerm === '') {
setFilteredTeam(teams);
} else {
setFilteredTeam(teams.filter((team) => team.display_name.includes(searchTerm) || team.name.includes(searchTerm)));
}
}, 200), [teams]);
}, [teams]), 200);
const handleOnPress = usePreventDoubleTap(useCallback((teamId: string) => {
selectTeam(teamId);
popTopScreen();
}, []));
}, [selectTeam]));
return (
<View

View file

@ -18,7 +18,7 @@ import {SafeAreaView} from 'react-native-safe-area-context';
import Autocomplete from '@components/autocomplete';
import ErrorText from '@components/error_text';
import FloatingTextInput from '@components/floating_text_input_label';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import FormattedText from '@components/formatted_text';
import Loading from '@components/loading';
import OptionItem from '@components/option_item';
@ -30,7 +30,6 @@ import {useInputPropagation} from '@hooks/input';
import {
changeOpacity,
makeStyleSheetFromTheme,
getKeyboardAppearanceFromTheme,
} from '@utils/theme';
import {typography} from '@utils/typography';
@ -185,6 +184,9 @@ export default function ChannelInfoForm({
if (!keyboardVisible && keyboardHeight) {
setKeyBoardVisible(true);
}
// We only want to change the visibility when the height changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyboardHeight]);
const onHeaderAutocompleteChange = useCallback((value: string) => {
@ -302,8 +304,6 @@ export default function ChannelInfoForm({
{!displayHeaderOnly && (
<>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
blurOnSubmit={false}
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
@ -311,10 +311,7 @@ export default function ChannelInfoForm({
placeholder={placeholderDisplayName}
onChangeText={onDisplayNameChange}
maxLength={Channel.MAX_CHANNEL_NAME_LENGTH}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
returnKeyType='next'
showErrorIcon={false}
spellCheck={false}
testID='channel_info_form.display_name.input'
value={displayName}
ref={nameInput}
@ -325,18 +322,13 @@ export default function ChannelInfoForm({
onLayout={onLayoutPurpose}
>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
blurOnSubmit={false}
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
label={labelPurpose}
placeholder={placeholderPurpose}
onChangeText={onPurposeChange}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
returnKeyType='next'
showErrorIcon={false}
spellCheck={false}
testID='channel_info_form.purpose.input'
value={purpose}
ref={purposeInput}
@ -353,8 +345,6 @@ export default function ChannelInfoForm({
)}
<View>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
blurOnSubmit={false}
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
@ -362,10 +352,7 @@ export default function ChannelInfoForm({
placeholder={placeholderHeader}
onChangeText={onHeaderInputChange}
multiline={true}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
returnKeyType='next'
showErrorIcon={false}
spellCheck={false}
testID='channel_info_form.header.input'
value={header}
ref={headerInput}

View file

@ -5,7 +5,7 @@ import React, {type ComponentProps} from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import FloatingTextInput from '@components/floating_text_input_label';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';

View file

@ -5,12 +5,12 @@ import React, {type ComponentProps, memo, useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Platform, type TextInputProps, View} from 'react-native';
import FloatingTextInput from '@components/floating_text_input_label';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
export type FieldProps = TextInputProps & {
export type FieldProps = {
isDisabled?: boolean;
fieldKey: string;
label: string;
@ -22,6 +22,10 @@ export type FieldProps = TextInputProps & {
value: string;
fieldRef: ComponentProps<typeof FloatingTextInput>['ref'];
onFocusNextField: (fieldKey: string) => void;
returnKeyType: TextInputProps['returnKeyType'];
blurOnSubmit: TextInputProps['blurOnSubmit'];
enablesReturnKeyAutomatically: TextInputProps['enablesReturnKeyAutomatically'];
keyboardType?: TextInputProps['keyboardType'];
};
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
@ -38,8 +42,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
});
const Field = ({
autoCapitalize = 'none',
autoCorrect = false,
fieldKey,
isDisabled = false,
isOptional = false,
@ -52,7 +54,7 @@ const Field = ({
fieldRef,
error,
onFocusNextField,
...props
...textInputProps
}: FieldProps) => {
const theme = useTheme();
const intl = useIntl();
@ -81,11 +83,9 @@ const Field = ({
style={subContainer}
>
<FloatingTextInput
autoCapitalize={autoCapitalize}
autoCorrect={autoCorrect}
rawInput={true}
disableFullscreenUI={true}
editable={!isDisabled}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
keyboardType={keyboard}
label={formattedLabel}
maxLength={maxLength}
@ -96,7 +96,7 @@ const Field = ({
value={value}
ref={fieldRef}
onSubmitEditing={onSubmitEditing}
{...props}
{...textInputProps}
/>
</View>
);

View file

@ -6,7 +6,7 @@ import {defineMessages, useIntl} from 'react-intl';
import {Keyboard, Platform, useWindowDimensions, View} from 'react-native';
import Button from '@components/button';
import FloatingTextInput, {type FloatingTextInputRef} from '@components/floating_text_input_label';
import FloatingTextInput, {type FloatingTextInputRef} from '@components/floating_input/floating_text_input_label';
import FormattedText from '@components/formatted_text';
import {useIsTablet} from '@hooks/device';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -120,7 +120,7 @@ const EditServerForm = ({
keyboardAwareRef.current?.scrollToPosition(0, 0);
}
}
}, [onFocus]);
}, [isTablet, keyboardAwareRef, onFocus]);
const saveButtonTestId = buttonDisabled ? 'edit_server_form.save.button.disabled' : 'edit_server_form.save.button';
@ -128,8 +128,7 @@ const EditServerForm = ({
<View style={styles.formContainer}>
<View style={[styles.fullWidth, displayNameError?.length ? styles.error : undefined]}>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
rawInput={true}
enablesReturnKeyAutomatically={true}
error={displayNameError}
label={formatMessage({
@ -142,7 +141,6 @@ const EditServerForm = ({
onSubmitEditing={onUpdate}
ref={displayNameRef}
returnKeyType='done'
spellCheck={false}
testID='edit_server_form.server_display_name.input'
theme={theme}
value={displayName}

View file

@ -7,7 +7,7 @@ import {StyleSheet, View} from 'react-native';
import {searchCustomEmojis} from '@actions/remote/custom_emoji';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import {useDebounce} from '@hooks/utils';
import SecurityManager from '@managers/security_manager';
import {getKeyboardAppearanceFromTheme} from '@utils/theme';
@ -46,16 +46,16 @@ const Picker = ({customEmojis, customEmojisEnabled, file, imageUrl, onEmojiPress
const onCancelSearch = useCallback(() => setSearchTerm(undefined), []);
const onChangeSearchTerm = useCallback((text: string) => {
setSearchTerm(text);
searchCustom(text.replace(/^:|:$/g, '').trim());
}, []);
const searchCustom = debounce((text: string) => {
const searchCustom = useDebounce(useCallback((text: string) => {
if (text && text.length > 1) {
searchCustomEmojis(serverUrl, text);
}
}, 500);
}, [serverUrl]), 500);
const onChangeSearchTerm = useCallback((text: string) => {
setSearchTerm(text);
searchCustom(text.replace(/^:|:$/g, '').trim());
}, [searchCustom]);
let EmojiList: React.ReactNode = null;
const term = searchTerm?.replace(/^:|:$/g, '').trim();

View file

@ -1,7 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {debounce, type DebouncedFunc} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Alert, FlatList, type ListRenderItemInfo, Platform, StyleSheet, View} from 'react-native';
@ -17,6 +16,7 @@ import ThreadsButton from '@components/threads_button';
import UserItem from '@components/user_item';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useDebounce} from '@hooks/utils';
import {sortChannelsByDisplayName} from '@utils/channel';
import {displayUsername} from '@utils/user';
@ -78,7 +78,6 @@ const FilteredList = ({
isCRTEnabled, keyboardOverlap, loading, onLoading, restrictDirectMessage, showTeamName,
teamIds, teammateDisplayNameSetting, term, usersMatch, usersMatchStart, testID,
}: Props) => {
const bounce = useRef<DebouncedFunc<() => void>>();
const mounted = useRef(false);
const serverUrl = useServerUrl();
const theme = useTheme();
@ -88,7 +87,7 @@ const FilteredList = ({
const totalLocalResults = channelsMatchStart.length + channelsMatch.length + usersMatchStart.length;
const search = async () => {
const search = useDebounce(useCallback(async () => {
onLoading(true);
if (mounted.current) {
setRemoteChannels({archived: [], startWith: [], matches: []});
@ -140,7 +139,7 @@ const FilteredList = ({
}
onLoading(false);
};
}, [archivedChannels, channelsMatch, channelsMatchStart, currentTeamId, locale, onLoading, restrictDirectMessage, serverUrl, teamIds, term, totalLocalResults]), 500);
const onJoinChannel = useCallback(async (c: Channel | ChannelModel) => {
const res = await joinChannelIfNeeded(serverUrl, c.id);
@ -158,7 +157,7 @@ const FilteredList = ({
await close();
switchToChannelById(serverUrl, c.id, undefined, true);
}, [serverUrl, close, locale]);
}, [serverUrl, close, formatMessage]);
const onOpenDirectMessage = useCallback(async (u: UserProfile | UserModel) => {
const displayName = displayUsername(u, locale, teammateDisplayNameSetting);
@ -176,7 +175,7 @@ const FilteredList = ({
await close();
switchToChannelById(serverUrl, data.id);
}, [serverUrl, close, locale, teammateDisplayNameSetting]);
}, [locale, teammateDisplayNameSetting, serverUrl, close, formatMessage]);
const onSwitchToChannel = useCallback(async (c: Channel | ChannelModel) => {
await close();
@ -254,14 +253,14 @@ const FilteredList = ({
testID='find_channels.filtered_list.remote_channel_item'
/>
);
}, [onJoinChannel, onOpenDirectMessage, onSwitchToChannel, showTeamName, teammateDisplayNameSetting]);
}, [onJoinChannel, onOpenDirectMessage, onSwitchToChannel, onSwitchToThreads, showTeamName]);
const threadLabel = useMemo(
() => formatMessage({
id: 'threads',
defaultMessage: 'Threads',
}).toLowerCase(),
[locale],
[formatMessage],
);
const data = useMemo(() => {
@ -324,13 +323,10 @@ const FilteredList = ({
}, []);
useEffect(() => {
bounce.current = debounce(search, 500);
bounce.current();
return () => {
if (bounce.current) {
bounce.current.cancel();
}
};
search();
// We only want to search if the term changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [term]);
return (

View file

@ -11,7 +11,7 @@ import {SafeAreaView} from 'react-native-safe-area-context';
import {sendPasswordResetEmail} from '@actions/remote/session';
import Button from '@components/button';
import FloatingTextInput from '@components/floating_text_input_label';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import FormattedText from '@components/formatted_text';
import {Screens} from '@constants';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
@ -209,8 +209,7 @@ const ForgotPassword = ({componentId, serverUrl, theme}: Props) => {
/>
<View style={styles.form}>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
rawInput={true}
blurOnSubmit={true}
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
@ -220,7 +219,6 @@ const ForgotPassword = ({componentId, serverUrl, theme}: Props) => {
onChangeText={changeEmail}
onSubmitEditing={submitResetPassword}
returnKeyType='next'
spellCheck={false}
testID='forgot.password.email'
theme={theme}
value={email}

View file

@ -14,9 +14,9 @@ import ServerUserList from '@components/server_user_list';
import {General, Screens, View as ViewConstants} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {useDebounce} from '@hooks/utils';
import SecurityManager from '@managers/security_manager';
import {
buildNavigationButton,
@ -191,7 +191,19 @@ function IntegrationSelector(
const [searchResults, setSearchResults] = useState<DataTypeList>([]);
// Channels and DialogOptions, will be removed
const [multiselectSelected, setMultiselectSelected] = useState<MultiselectSelectedMap>({});
const [multiselectSelected, setMultiselectSelected] = useState<MultiselectSelectedMap>(() => {
const multiselectItems: MultiselectSelectedMap = {};
if (isMultiselect && Array.isArray(selected) && !([ViewConstants.DATA_SOURCE_USERS, ViewConstants.DATA_SOURCE_CHANNELS].includes(dataSource))) {
for (const value of selected) {
const option = filteredOptions?.find((opt) => opt.value === value);
if (option) {
multiselectItems[value] = option;
}
}
}
return multiselectItems;
});
// Users selection and in the future Channels and DialogOptions
const [selectedIds, setSelectedIds] = useState<{[id: string]: DataType}>({});
@ -264,7 +276,7 @@ function IntegrationSelector(
}
}, [dataSource]);
const getChannels = useCallback(debounce(async () => {
const getChannels = useDebounce(useCallback(async () => {
if (next.current && !loading && !term) {
setLoading(true);
page.current += 1;
@ -279,7 +291,7 @@ function IntegrationSelector(
next.current = false;
}
}
}, 100), [loading, term, serverUrl, currentTeamId, integrationData]);
}, [loading, term, serverUrl, currentTeamId, integrationData]), 100);
const loadMore = useCallback(async () => {
if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) {
@ -384,6 +396,9 @@ function IntegrationSelector(
// Static and dynamic option search
searchDynamicOptions('');
}
// We only want to get the channels on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@ -398,6 +413,9 @@ function IntegrationSelector(
}
setCustomListData(listData);
// We only want to update the list data when the search results or integration data changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchResults, integrationData]);
useEffect(() => {
@ -410,21 +428,6 @@ function IntegrationSelector(
});
}, [rightButton, componentId, isMultiselect]);
useEffect(() => {
const multiselectItems: MultiselectSelectedMap = {};
if (isMultiselect && Array.isArray(selected) && !([ViewConstants.DATA_SOURCE_USERS, ViewConstants.DATA_SOURCE_CHANNELS].includes(dataSource))) {
for (const value of selected) {
const option = filteredOptions?.find((opt) => opt.value === value);
if (option) {
multiselectItems[value] = option;
}
}
setMultiselectSelected(multiselectItems);
}
}, []);
// Renders
const renderLoading = useCallback(() => {
if (!loading) {

View file

@ -10,7 +10,7 @@ import {Keyboard, TextInput, TouchableOpacity, View} from 'react-native';
import {login} from '@actions/remote/session';
import Button from '@components/button';
import CompassIcon from '@components/compass_icon';
import FloatingTextInput from '@components/floating_text_input_label';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import FormattedText from '@components/formatted_text';
import {FORGOT_PASSWORD, MFA} from '@constants/screens';
import {useAvoidKeyboard} from '@hooks/device';
@ -37,16 +37,7 @@ const hitSlop = {top: 8, right: 8, bottom: 8, left: 8};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
marginBottom: 24,
},
inputBoxEmail: {
marginTop: 16,
marginBottom: 5,
color: theme.centerChannelColor,
},
inputBoxPassword: {
marginTop: 24,
marginBottom: 11,
color: theme.centerChannelColor,
gap: 24,
},
forgotPasswordBtn: {
backgroundColor: 'transparent',
@ -56,17 +47,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
borderColor: 'transparent',
width: '60%',
},
forgotPasswordError: {
marginTop: 30,
},
forgotPasswordTxt: {
paddingVertical: 10,
color: theme.buttonBg,
fontSize: 14,
fontFamily: 'OpenSans-SemiBold',
},
loginButtonContainer: {
marginTop: 25,
marginTop: 20,
},
endAdornment: {
top: 2,
@ -290,10 +277,8 @@ const LoginForm = ({config, extra, keyboardAwareRef, serverDisplayName, launchEr
return (
<View style={styles.container}>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
rawInput={true}
blurOnSubmit={false}
containerStyle={styles.inputBoxEmail}
autoComplete='email'
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
@ -304,17 +289,14 @@ const LoginForm = ({config, extra, keyboardAwareRef, serverDisplayName, launchEr
onSubmitEditing={focusPassword}
ref={loginRef}
returnKeyType='next'
showErrorIcon={false}
spellCheck={false}
hideErrorIcon={true}
testID='login_form.username.input'
theme={theme}
value={loginId}
/>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
rawInput={true}
blurOnSubmit={false}
containerStyle={styles.inputBoxPassword}
autoComplete='current-password'
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
@ -325,7 +307,6 @@ const LoginForm = ({config, extra, keyboardAwareRef, serverDisplayName, launchEr
onSubmitEditing={onLogin}
ref={passwordRef}
returnKeyType='join'
spellCheck={false}
secureTextEntry={!isPasswordVisible}
testID='login_form.password.input'
theme={theme}
@ -336,7 +317,7 @@ const LoginForm = ({config, extra, keyboardAwareRef, serverDisplayName, launchEr
{(emailEnabled || usernameEnabled) && config.PasswordEnableForgotLink !== 'false' && (
<RNEButton
onPress={onPressForgotPassword}
buttonStyle={[styles.forgotPasswordBtn, error ? styles.forgotPasswordError : undefined]}
buttonStyle={styles.forgotPasswordBtn}
testID='login_form.forgot_password.button'
>
<FormattedText

View file

@ -10,7 +10,7 @@ import {SafeAreaView} from 'react-native-safe-area-context';
import {login} from '@actions/remote/session';
import Button from '@components/button';
import FloatingTextInput from '@components/floating_text_input_label';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import FormattedText from '@components/formatted_text';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useAvoidKeyboard} from '@hooks/device';
@ -168,8 +168,7 @@ const MFA = ({componentId, config, goToHome, license, loginId, password, serverD
/>
<View style={styles.form}>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
rawInput={true}
blurOnSubmit={true}
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
@ -179,7 +178,6 @@ const MFA = ({componentId, config, goToHome, license, loginId, password, serverD
onChangeText={handleInput}
onSubmitEditing={submit}
returnKeyType='go'
spellCheck={false}
testID='login_mfa.input'
theme={theme}
value={token}

View file

@ -8,7 +8,7 @@ import Animated, {FlipInEasyX, FlipOutXUp, useAnimatedStyle, useSharedValue, wit
import Button from '@components/button';
import CompassIcon from '@components/compass_icon';
import FloatingTextInput, {type FloatingTextInputRef} from '@components/floating_text_input_label';
import FloatingTextInput, {type FloatingTextInputRef} from '@components/floating_input/floating_text_input_label';
import FormattedText from '@components/formatted_text';
import {useAvoidKeyboard} from '@hooks/device';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -44,9 +44,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
width: '100%',
paddingHorizontal: 20,
},
enterServer: {
marginBottom: 24,
},
fullWidth: {
width: '100%',
},
@ -80,6 +77,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
marginLeft: 20,
marginRight: 20,
},
inputsContainer: {
gap: 24,
width: '100%',
},
}));
const messages = defineMessages({
@ -181,12 +182,10 @@ const ServerForm = ({
return (
<View style={styles.formContainer}>
<View style={styles.fullWidth}>
<View style={styles.inputsContainer}>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
rawInput={true}
autoFocus={autoFocus}
containerStyle={styles.enterServer}
enablesReturnKeyAutomatically={true}
editable={!disableServerUrl}
error={urlError}
@ -199,16 +198,12 @@ const ServerForm = ({
onSubmitEditing={onUrlSubmit}
ref={urlRef}
returnKeyType='next'
spellCheck={false}
testID='server_form.server_url.input'
theme={theme}
value={url}
/>
</View>
<View style={styles.fullWidth}>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
rawInput={true}
enablesReturnKeyAutomatically={true}
error={displayNameError}
label={formatMessage({
@ -218,8 +213,7 @@ const ServerForm = ({
onChangeText={handleDisplayNameTextChanged}
onSubmitEditing={onDisplayNameSubmit}
ref={displayNameRef}
returnKeyType={showAdvancedOptions || preauthSecretError ? 'next' : 'done'}
spellCheck={false}
returnKeyType={showAdvancedOptions ? 'next' : 'done'}
testID='server_form.server_display_name.input'
theme={theme}
value={displayName}
@ -262,8 +256,7 @@ const ServerForm = ({
exiting={FlipOutXUp.duration(250)}
>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
rawInput={true}
enablesReturnKeyAutomatically={true}
error={preauthSecretError}
label={formatMessage(messages.preauthSecret)}
@ -272,7 +265,6 @@ const ServerForm = ({
ref={preauthSecretRef}
returnKeyType='done'
secureTextEntry={true}
spellCheck={false}
testID='server_form.preauth_secret.input'
theme={theme}
value={preauthSecret}

View file

@ -1,11 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import React, {useCallback, useState} from 'react';
import {defineMessage, useIntl} from 'react-intl';
import {View} from 'react-native';
import {fetchStatusInBatch, updateMe} from '@actions/remote/user';
import FloatingTextInput from '@components/floating_text_input_label';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import FormattedText from '@components/formatted_text';
import SettingContainer from '@components/settings/container';
import SettingOption from '@components/settings/option';
@ -16,7 +17,7 @@ import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useBackNavigation from '@hooks/navigate_back';
import {popTopScreen} from '@screens/navigation';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {getNotificationProps} from '@utils/user';
@ -35,22 +36,12 @@ const OOO = defineMessage({
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
input: {
color: theme.centerChannelColor,
...typography('Body', 200, 'Regular'),
flex: 1,
},
textInputContainer: {
width: '91%',
marginTop: 20,
alignSelf: 'center',
height: 154,
},
footer: {
paddingHorizontal: 20,
color: changeOpacity(theme.centerChannelColor, 0.5),
...typography('Body', 75, 'Regular'),
marginTop: 20,
},
container: {
gap: 20,
},
};
});
@ -63,12 +54,12 @@ const NotificationAutoResponder = ({currentUser, componentId}: NotificationAutoR
const theme = useTheme();
const serverUrl = useServerUrl();
const intl = useIntl();
const notifyProps = useMemo(() => getNotificationProps(currentUser), [/* dependency array should remain empty */]);
const [notifyProps] = useState(() => getNotificationProps(currentUser));
const initialAutoResponderActive = useMemo(() => Boolean(currentUser?.status === General.OUT_OF_OFFICE && notifyProps.auto_responder_active === 'true'), [/* dependency array should remain empty */]);
const [initialAutoResponderActive] = useState(() => Boolean(currentUser?.status === General.OUT_OF_OFFICE && notifyProps.auto_responder_active === 'true'));
const [autoResponderActive, setAutoResponderActive] = useState<boolean>(initialAutoResponderActive);
const initialOOOMsg = useMemo(() => notifyProps.auto_responder_message || intl.formatMessage(OOO), [/* dependency array should remain empty */]);
const [initialOOOMsg] = useState(() => notifyProps.auto_responder_message || intl.formatMessage(OOO));
const [autoResponderMessage, setAutoResponderMessage] = useState<string>(initialOOOMsg);
const styles = getStyleSheet(theme);
@ -97,42 +88,34 @@ const NotificationAutoResponder = ({currentUser, componentId}: NotificationAutoR
return (
<SettingContainer testID='auto_responder_notification_settings'>
<SettingOption
label={intl.formatMessage({id: 'notification_settings.auto_responder.to.enable', defaultMessage: 'Enable automatic replies'})}
action={setAutoResponderActive}
testID='auto_responder_notification_settings.enable_automatic_replies.option'
type='toggle'
selected={autoResponderActive}
/>
<SettingSeparator/>
{autoResponderActive && (
<FloatingTextInput
allowFontScaling={true}
autoCapitalize='none'
autoCorrect={false}
containerStyle={styles.textInputContainer}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
label={intl.formatMessage(label)}
multiline={true}
multilineInputHeight={154}
onChangeText={setAutoResponderMessage}
placeholder={intl.formatMessage(label)}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.4)}
returnKeyType='default'
testID='auto_responder_notification_settings.message.input'
textAlignVertical='top'
textInputStyle={styles.input}
theme={theme}
underlineColorAndroid='transparent'
value={autoResponderMessage || ''}
<View style={styles.container}>
<SettingOption
label={intl.formatMessage({id: 'notification_settings.auto_responder.to.enable', defaultMessage: 'Enable automatic replies'})}
action={setAutoResponderActive}
testID='auto_responder_notification_settings.enable_automatic_replies.option'
type='toggle'
selected={autoResponderActive}
/>
)}
<FormattedText
id={'notification_settings.auto_responder.footer.message'}
defaultMessage={'Set a custom message that is automatically sent in response to direct messages, such as an out of office or vacation reply. Enabling this setting changes your status to Out of Office and disables notifications.'}
style={styles.footer}
testID='auto_responder_notification_settings.message.input.description'
/>
<SettingSeparator/>
{autoResponderActive && (
<FloatingTextInput
label={intl.formatMessage(label)}
multiline={true}
multilineInputHeight={154}
onChangeText={setAutoResponderMessage}
placeholder={intl.formatMessage(label)}
testID='auto_responder_notification_settings.message.input'
theme={theme}
value={autoResponderMessage || ''}
/>
)}
<FormattedText
id={'notification_settings.auto_responder.footer.message'}
defaultMessage={'Set a custom message that is automatically sent in response to direct messages, such as an out of office or vacation reply. Enabling this setting changes your status to Out of Office and disables notifications.'}
style={styles.footer}
testID='auto_responder_notification_settings.message.input.description'
/>
</View>
</SettingContainer>
);
};

View file

@ -1,13 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import React, {useCallback, useState} from 'react';
import {defineMessage, useIntl} from 'react-intl';
import {Text} from 'react-native';
import {Text, View} from 'react-native';
import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view';
import {updateMe} from '@actions/remote/user';
import FloatingTextChipsInput from '@components/floating_text_chips_input';
import FloatingTextChipsInput from '@components/floating_input/floating_text_chips_input';
import SettingBlock from '@components/settings/block';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
@ -18,7 +18,7 @@ import useBackNavigation from '@hooks/navigate_back';
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 {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {getNotificationProps} from '@utils/user';
@ -35,18 +35,12 @@ const COMMA_KEY = ',';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
flex: {flex: 1},
input: {
color: theme.centerChannelColor,
paddingHorizontal: 15,
...typography('Body', 100, 'Regular'),
},
containerStyle: {
flexDirection: 'row',
marginTop: 30,
alignSelf: 'center',
paddingHorizontal: 18.5,
},
keywordLabelStyle: {
paddingHorizontal: 18.5,
marginTop: 4,
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75, 'Regular'),
@ -117,7 +111,7 @@ export function getUniqueKeywordsFromInput(inputText: string, keywords: string[]
const MentionSettings = ({componentId, currentUser, isCRTEnabled}: Props) => {
const serverUrl = useServerUrl();
const mentionProps = useMemo(() => getMentionProps(currentUser), []);
const [mentionProps] = useState(() => getMentionProps(currentUser));
const notifyProps = mentionProps.notifyProps;
const [mentionKeywords, setMentionKeywords] = useState(mentionProps.mentionKeywords);
@ -264,29 +258,23 @@ const MentionSettings = ({componentId, currentUser, isCRTEnabled}: Props) => {
type='toggle'
/>
<SettingSeparator/>
<FloatingTextChipsInput
allowFontScaling={true}
autoCapitalize='none'
autoCorrect={false}
blurOnSubmit={true}
containerStyle={styles.containerStyle}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
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='center'
theme={theme}
underlineColorAndroid='transparent'
chipsValues={mentionKeywords}
textInputValue={mentionKeywordsInput}
onTextInputSubmitted={handleMentionKeywordEntered}
/>
<View style={styles.containerStyle}>
<FloatingTextChipsInput
blurOnSubmit={true}
label={intl.formatMessage({
id: 'notification_settings.mentions.keywords',
defaultMessage: 'Enter other keywords',
})}
onTextInputChange={handleMentionKeywordsInputChanged}
onChipRemove={handleMentionKeywordRemoved}
returnKeyType='done'
testID='mention_notification_settings.keywords.input'
theme={theme}
chipsValues={mentionKeywords}
textInputValue={mentionKeywordsInput}
onTextInputSubmitted={handleMentionKeywordEntered}
/>
</View>
<Text
style={styles.keywordLabelStyle}
testID='mention_notification_settings.keywords.input.description'

View file

@ -11,9 +11,9 @@ import PostList from '@components/post_list';
import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import {useAppState} from '@hooks/device';
import {useFetchingThreadState} from '@hooks/fetching_thread';
import {useDebounce} from '@hooks/utils';
import {isMinimumServerVersion} from '@utils/helpers';
import type PostModel from '@typings/database/models/servers/post';
@ -46,7 +46,7 @@ const ThreadPostList = ({
const isFetchingThread = useFetchingThreadState(rootPost.id);
const canLoadMorePosts = useRef(true);
const onEndReached = useCallback(debounce(async () => {
const onEndReached = useDebounce(useCallback(async () => {
if (isMinimumServerVersion(version || '', 6, 7) && !isFetchingThread && canLoadMorePosts.current && posts.length) {
const options: FetchPaginatedThreadOptions = {
perPage: PER_PAGE_DEFAULT,
@ -63,7 +63,7 @@ const ThreadPostList = ({
} else {
canLoadMorePosts.current = false;
}
}, 500), [isFetchingThread, rootPost, posts, version]);
}, [version, isFetchingThread, posts, serverUrl, rootPost.id]), 500);
const threadPosts = useMemo(() => {
return [...posts, rootPost];
@ -74,6 +74,9 @@ const ThreadPostList = ({
if (isCRTEnabled && thread?.isFollowing) {
markThreadAsRead(serverUrl, teamId, rootPost.id);
}
// We only want to mark the thread as read on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// If CRT is enabled, When new post arrives and thread modal is open, mark thread as read.

View file

@ -1,40 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {debounce} from 'lodash';
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
import {StyleSheet, View} from 'react-native';
import FloatingTextInput from '@components/floating_text_input_label';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import {useDebounce} from '@hooks/utils';
import {setShareExtensionMessage, useShareExtensionMessage} from '@share/state';
import {getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
theme: Theme;
}
const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
const styles = StyleSheet.create({
container: {
marginHorizontal: 20,
},
input: {
color: theme.centerChannelColor,
minHeight: 154,
...typography('Body', 200, 'Regular'),
},
textInputContainer: {
marginTop: 20,
alignSelf: 'center',
minHeight: 154,
height: undefined,
},
}));
});
const Message = ({theme}: Props) => {
const intl = useIntl();
const styles = getStyles(theme);
const message = useShareExtensionMessage();
const label = useMemo(() => {
@ -42,30 +29,21 @@ const Message = ({theme}: Props) => {
id: 'share_extension.message',
defaultMessage: 'Enter a message (optional)',
});
}, [intl.locale]);
}, [intl]);
const onChangeText = useCallback(debounce((text: string) => {
const onChangeText = useDebounce(useCallback(((text: string) => {
setShareExtensionMessage(text);
}, 250), []);
}), []), 250);
return (
<View style={styles.container}>
<FloatingTextInput
allowFontScaling={false}
autoCapitalize='none'
autoCorrect={false}
autoFocus={false}
containerStyle={styles.textInputContainer}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
label={label}
multiline={true}
onChangeText={onChangeText}
returnKeyType='default'
textAlignVertical='top'
textInputStyle={styles.input}
theme={theme}
underlineColorAndroid='transparent'
defaultValue={message || ''}
multilineInputHeight={154}
/>
</View>
);