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:
parent
ffa40dac11
commit
3efc301da5
40 changed files with 1073 additions and 979 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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));
|
||||
237
app/components/floating_input/floating_input_container.tsx
Normal file
237
app/components/floating_input/floating_input_container.tsx
Normal 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;
|
||||
146
app/components/floating_input/floating_text_chips_input.tsx
Normal file
146
app/components/floating_input/floating_text_chips_input.tsx
Normal 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;
|
||||
170
app/components/floating_input/floating_text_input_label.tsx
Normal file
170
app/components/floating_input/floating_text_input_label.tsx
Normal 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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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];
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>>();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}/>);
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue