From 3efc301da59af661d9aa60ab5b1347f2bb75d364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Wed, 1 Oct 2025 13:31:16 +0200 Subject: [PATCH] Add Floating Label Autocomplete Selector (#9119) * Add Floating Label Autocomplete Selector * Address feedback * Revert unintended change * Move database connection to index file --- .../autocomplete/at_mention/at_mention.tsx | 15 +- .../channel_mention/channel_mention.tsx | 11 +- .../emoji_suggestion/emoji_suggestion.tsx | 16 +- .../app_slash_suggestion.tsx | 23 +- .../slash_suggestion/slash_suggestion.tsx | 12 +- .../floating_autocomplete_selector.tsx | 231 ++++++++++++ .../floating_autocomplete_selector/index.ts | 18 + .../floating_input_container.tsx | 237 +++++++++++++ .../floating_text_chips_input.tsx | 146 ++++++++ .../floating_text_input_label.tsx | 170 +++++++++ .../utils.test.ts | 0 .../utils.ts | 0 .../floating_text_chips_input/index.tsx | 330 ------------------ .../floating_text_chips_input/utils.ts | 19 - .../floating_text_input_label/index.tsx | 301 ---------------- app/components/server_user_list/index.tsx | 19 +- app/hooks/field_refs.ts | 2 +- app/hooks/utils.ts | 21 +- .../edit_command/edit_command.test.tsx | 2 +- .../edit_command/edit_command_form.test.tsx | 10 +- .../edit_command/edit_command_form.tsx | 12 +- .../channel_post_list/channel_post_list.tsx | 12 +- .../components/bookmark_link.tsx | 23 +- .../channel_name_input.tsx | 8 +- .../team_selector_list/index.tsx | 9 +- .../channel_info_form.tsx | 21 +- .../edit_profile/components/email_field.tsx | 2 +- app/screens/edit_profile/components/field.tsx | 20 +- app/screens/edit_server/form.tsx | 8 +- app/screens/emoji_picker/picker/picker.tsx | 16 +- .../filtered_list/filtered_list.tsx | 26 +- app/screens/forgot_password/index.tsx | 6 +- .../integration_selector.tsx | 41 ++- app/screens/login/form.tsx | 33 +- app/screens/mfa/index.tsx | 6 +- app/screens/server/form.tsx | 28 +- .../notification_auto_responder.tsx | 91 ++--- .../notification_mention/mention_settings.tsx | 58 ++- .../thread_post_list/thread_post_list.tsx | 9 +- .../components/content_view/message/index.tsx | 40 +-- 40 files changed, 1073 insertions(+), 979 deletions(-) create mode 100644 app/components/floating_input/floating_autocomplete_selector/floating_autocomplete_selector.tsx create mode 100644 app/components/floating_input/floating_autocomplete_selector/index.ts create mode 100644 app/components/floating_input/floating_input_container.tsx create mode 100644 app/components/floating_input/floating_text_chips_input.tsx create mode 100644 app/components/floating_input/floating_text_input_label.tsx rename app/components/{floating_text_chips_input => floating_input}/utils.test.ts (100%) rename app/components/{floating_text_input_label => floating_input}/utils.ts (100%) delete mode 100644 app/components/floating_text_chips_input/index.tsx delete mode 100644 app/components/floating_text_chips_input/utils.ts delete mode 100644 app/components/floating_text_input_label/index.tsx diff --git a/app/components/autocomplete/at_mention/at_mention.tsx b/app/components/autocomplete/at_mention/at_mention.tsx index 40418979a..518179470 100644 --- a/app/components/autocomplete/at_mention/at_mention.tsx +++ b/app/components/autocomplete/at_mention/at_mention.tsx @@ -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) { diff --git a/app/components/autocomplete/channel_mention/channel_mention.tsx b/app/components/autocomplete/channel_mention/channel_mention.tsx index f4315ff4e..1845cd8b5 100644 --- a/app/components/autocomplete/channel_mention/channel_mention.tsx +++ b/app/components/autocomplete/channel_mention/channel_mention.tsx @@ -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(() => { diff --git a/app/components/autocomplete/emoji_suggestion/emoji_suggestion.tsx b/app/components/autocomplete/emoji_suggestion/emoji_suggestion.tsx index 8587565dd..bcdafc4bd 100644 --- a/app/components/autocomplete/emoji_suggestion/emoji_suggestion.tsx +++ b/app/components/autocomplete/emoji_suggestion/emoji_suggestion.tsx @@ -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) { diff --git a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx index 9bfb09335..c0620a85a 100644 --- a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx +++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx @@ -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(() => { diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx index b4cd0e129..c90a76c23 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx @@ -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}) => ( { diff --git a/app/components/floating_input/floating_autocomplete_selector/floating_autocomplete_selector.tsx b/app/components/floating_input/floating_autocomplete_selector/floating_autocomplete_selector.tsx new file mode 100644 index 000000000..57228bd0e --- /dev/null +++ b/app/components/floating_input/floating_autocomplete_selector/floating_autocomplete_selector.tsx @@ -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; + 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 { + 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 = [{flex: 1}]; + if (disabled) { + res.push(style.disabled); + } + + return res; + }, [disabled, style.disabled]); + + const dropdownTextStyle = useMemo(() => { + const res: StyleProp = [style.dropdownText]; + if (!hasSelected) { + res.push(style.dropdownPlaceholder); + } + + return res; + }, [hasSelected, style.dropdownPlaceholder, style.dropdownText]); + + return ( + + + + + {itemText || placeholder} + + + + + + ); +} + +export default AutoCompleteSelector; diff --git a/app/components/floating_input/floating_autocomplete_selector/index.ts b/app/components/floating_input/floating_autocomplete_selector/index.ts new file mode 100644 index 000000000..2606f41d6 --- /dev/null +++ b/app/components/floating_input/floating_autocomplete_selector/index.ts @@ -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)); diff --git a/app/components/floating_input/floating_input_container.tsx b/app/components/floating_input/floating_input_container.tsx new file mode 100644 index 000000000..e1bc3d10e --- /dev/null +++ b/app/components/floating_input/floating_input_container.tsx @@ -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 = [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 ( + + + + + {label} + + + {children} + + + {Boolean(error) && ( + + {!hideErrorIcon && errorIcon && + + } + + {error} + + + )} + {Boolean(helpText) && ( + + {helpText} + + )} + + + ); +}; + +export default FloatingInputContainer; diff --git a/app/components/floating_input/floating_text_chips_input.tsx b/app/components/floating_input/floating_text_chips_input.tsx new file mode 100644 index 000000000..f42c9e808 --- /dev/null +++ b/app/components/floating_input/floating_text_chips_input.tsx @@ -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(({ + 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(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 ( + + {chipsValues && chipsValues?.length > 0 && chipsValues.map((chipValue) => ( + + ))} + + + ); +}); + +FloatingTextChipsInput.displayName = 'FloatingTextChipsInput'; +export default FloatingTextChipsInput; diff --git a/app/components/floating_input/floating_text_input_label.tsx b/app/components/floating_input/floating_text_input_label.tsx new file mode 100644 index 000000000..c5e9b6a43 --- /dev/null +++ b/app/components/floating_input/floating_text_input_label.tsx @@ -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) => void; + onFocus?: (e: NativeSyntheticEvent) => 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(({ + 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(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) => onExecution(e, + () => { + setIsFocused(false); + }, + onBlur, + ), [onBlur]); + + const onTextInputFocus = useCallback((e: NativeSyntheticEvent) => onExecution(e, + () => { + setIsFocused(true); + }, + onFocus, + ), [onFocus]); + + const defaultHeight = multiline ? multilineInputHeight || 100 : DEFAULT_INPUT_HEIGHT; + const combinedTextInputStyle = useMemo(() => { + const res: StyleProp = [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 ( + + + {endAdornment} + + ); +}); + +FloatingTextInput.displayName = 'FloatingTextInput'; + +export default FloatingTextInput; + diff --git a/app/components/floating_text_chips_input/utils.test.ts b/app/components/floating_input/utils.test.ts similarity index 100% rename from app/components/floating_text_chips_input/utils.test.ts rename to app/components/floating_input/utils.test.ts diff --git a/app/components/floating_text_input_label/utils.ts b/app/components/floating_input/utils.ts similarity index 100% rename from app/components/floating_text_input_label/utils.ts rename to app/components/floating_input/utils.ts diff --git a/app/components/floating_text_chips_input/index.tsx b/app/components/floating_text_chips_input/index.tsx deleted file mode 100644 index 1092c507d..000000000 --- a/app/components/floating_text_chips_input/index.tsx +++ /dev/null @@ -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; - -type Props = TextInputPropsFiltered & { - containerStyle?: StyleProp; - editable?: boolean; - error?: string; - errorIcon?: string; - isKeyboardInput?: boolean; - label: string; - labelTextStyle?: TextStyle; - onBlur?: (event: NativeSyntheticEvent) => void; - onFocus?: (e: NativeSyntheticEvent) => void; - onLayout?: (e: LayoutChangeEvent) => void; - onPress?: (e: GestureResponderEvent) => void; - placeholder?: string; - showErrorIcon?: boolean; - testID?: string; - textInputStyle?: TextStyle; - theme: Theme; - chipsValues?: string[]; - textInputValue: string; - onTextInputChange: TextInputProps['onChangeText']; - onChipRemove: (value: string) => void; - onTextInputSubmitted: () => void; -} - -const FloatingTextChipsInput = forwardRef(({ - textInputValue, - textInputStyle, - onTextInputChange, - onTextInputSubmitted, - chipsValues, - onChipRemove, - theme, - containerStyle, - editable = true, - error, - errorIcon = 'alert-outline', - isKeyboardInput = true, - label = '', - labelTextStyle, - onBlur, - onFocus, - onLayout, - onPress, - placeholder, - showErrorIcon = true, - testID, - ...restProps -}, ref) => { - const [focused, setIsFocused] = useState(false); - const [focusedLabel, setIsFocusLabel] = useState(); - - const inputRef = useRef(null); - - const styles = getStyleSheet(theme); - - const hasValues = textInputValue.length > 0 || (chipsValues?.length ?? 0) > 0; - - const shouldShowError = !focused && error; - - const positions = useMemo(() => getLabelPositions(styles.textInput, styles.label, styles.smallLabel), [styles]); - - // Exposes the blur, focus and isFocused methods to the parent component - useImperativeHandle(ref, () => ({ - blur: () => inputRef.current?.blur(), - focus: () => inputRef.current?.focus(), - isFocused: () => inputRef.current?.isFocused() || false, - }), [inputRef]); - - const onTextInputBlur = useCallback((e: NativeSyntheticEvent) => { - setIsFocusLabel(hasValues); - setIsFocused(false); - - onBlur?.(e); - }, [onBlur, hasValues]); - - const onTextInputFocus = useCallback((e: NativeSyntheticEvent) => { - setIsFocusLabel(true); - setIsFocused(true); - - onFocus?.(e); - }, [onFocus]); - - function handlePressOnContainer() { - if (!focused) { - inputRef?.current?.focus(); - } - } - - function handleTouchableOnPress(event: GestureResponderEvent) { - if (!isKeyboardInput && editable && onPress) { - onPress(event); - } - } - - const textInputContainerStyles = useMemo(() => { - const res: StyleProp = [styles.textInput]; - if (!editable) { - res.push(styles.readOnly); - } - res.push({ - borderWidth: focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH, - minHeight: (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 ( - - - - - {label} - - }> - {chipsValues && chipsValues?.length > 0 && chipsValues.map((chipValue) => ( - - ))} - - - - {Boolean(error) && ( - - {showErrorIcon && errorIcon && - - } - - {error} - - - )} - - - ); -}); - -FloatingTextChipsInput.displayName = 'FloatingTextChipsInput'; -export default FloatingTextChipsInput; diff --git a/app/components/floating_text_chips_input/utils.ts b/app/components/floating_text_chips_input/utils.ts deleted file mode 100644 index 77d26e2e9..000000000 --- a/app/components/floating_text_chips_input/utils.ts +++ /dev/null @@ -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]; -}; - diff --git a/app/components/floating_text_input_label/index.tsx b/app/components/floating_text_input_label/index.tsx deleted file mode 100644 index 723c1d919..000000000 --- a/app/components/floating_text_input_label/index.tsx +++ /dev/null @@ -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) => void; - onFocus?: (e: NativeSyntheticEvent) => void; - onLayout?: (e: LayoutChangeEvent) => void; - onPress?: (e: GestureResponderEvent) => void; - placeholder?: string; - showErrorIcon?: boolean; - testID?: string; - textInputStyle?: TextStyle; - theme: Theme; - value?: string; -} - -const FloatingTextInput = forwardRef(({ - 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(); - const inputRef = useRef(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) => onExecution(e, - () => { - setIsFocusLabel(Boolean(value)); - setIsFocused(false); - }, - onBlur, - ), [onBlur]); - - const onTextInputFocus = useCallback((e: NativeSyntheticEvent) => 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 = [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 = [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 ( - - - - {label} - - }> - - {endAdornment} - - {Boolean(error) && ( - - {showErrorIcon && errorIcon && - - } - - {error} - - - )} - - - ); -}); - -FloatingTextInput.displayName = 'FloatingTextInput'; - -export default FloatingTextInput; - diff --git a/app/components/server_user_list/index.tsx b/app/components/server_user_list/index.tsx index 541f5ae84..70e5d64a4 100644 --- a/app/components/server_user_list/index.tsx +++ b/app/components/server_user_list/index.tsx @@ -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(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 ( FloatingTextInputRef | undefined, (key: string) => (providedRef: FloatingTextInputRef) => () => void] => { const allRefs = useRef>(); diff --git a/app/hooks/utils.ts b/app/hooks/utils.ts index 314d4d0f8..295848d54 100644 --- a/app/hooks/utils.ts +++ b/app/hooks/utils.ts @@ -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 = (callback: T) => { callback(...args); }, [callback]); }; + +export const useDebounce = (callback: T, delay: number) => { + const timeoutRef = useRef(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]); +}; diff --git a/app/products/playbooks/screens/edit_command/edit_command.test.tsx b/app/products/playbooks/screens/edit_command/edit_command.test.tsx index 2150cb761..6a46f90b5 100644 --- a/app/products/playbooks/screens/edit_command/edit_command.test.tsx +++ b/app/products/playbooks/screens/edit_command/edit_command.test.tsx @@ -40,7 +40,7 @@ describe('EditCommand', () => { function getBaseProps(): ComponentProps { return { - componentId: 'EditCommand' as const, + componentId: 'PlaybookEditCommand', savedCommand: '/test command', updateCommand: jest.fn(), channelId: 'channel-123', diff --git a/app/products/playbooks/screens/edit_command/edit_command_form.test.tsx b/app/products/playbooks/screens/edit_command/edit_command_form.test.tsx index cab071deb..3cc1d6d52 100644 --- a/app/products/playbooks/screens/edit_command/edit_command_form.test.tsx +++ b/app/products/playbooks/screens/edit_command/edit_command_form.test.tsx @@ -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); diff --git a/app/products/playbooks/screens/edit_command/edit_command_form.tsx b/app/products/playbooks/screens/edit_command/edit_command_form.tsx index 050402337..f69abee46 100644 --- a/app/products/playbooks/screens/edit_command/edit_command_form.tsx +++ b/app/products/playbooks/screens/edit_command/edit_command_form.tsx @@ -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({ > (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 = (); diff --git a/app/screens/channel_bookmark/components/bookmark_link.tsx b/app/screens/channel_bookmark/components/bookmark_link.tsx index 6df862dd1..5e72bf9fb 100644 --- a/app/screens/channel_bookmark/components/bookmark_link.tsx +++ b/app/screens/channel_bookmark/components/bookmark_link.tsx @@ -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 ( { return ( { 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 ( { @@ -302,8 +304,6 @@ export default function ChannelInfoForm({ {!displayHeaderOnly && ( <> ['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} > ); diff --git a/app/screens/edit_server/form.tsx b/app/screens/edit_server/form.tsx index 34c84e118..6fc2f016f 100644 --- a/app/screens/edit_server/form.tsx +++ b/app/screens/edit_server/form.tsx @@ -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 = ({ 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(); diff --git a/app/screens/find_channels/filtered_list/filtered_list.tsx b/app/screens/find_channels/filtered_list/filtered_list.tsx index 6d7cb6948..fcbd7b09e 100644 --- a/app/screens/find_channels/filtered_list/filtered_list.tsx +++ b/app/screens/find_channels/filtered_list/filtered_list.tsx @@ -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 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 ( diff --git a/app/screens/forgot_password/index.tsx b/app/screens/forgot_password/index.tsx index 0633ee457..f3b671818 100644 --- a/app/screens/forgot_password/index.tsx +++ b/app/screens/forgot_password/index.tsx @@ -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) => { /> { onChangeText={changeEmail} onSubmitEditing={submitResetPassword} returnKeyType='next' - spellCheck={false} testID='forgot.password.email' theme={theme} value={email} diff --git a/app/screens/integration_selector/integration_selector.tsx b/app/screens/integration_selector/integration_selector.tsx index 6d589ae16..7f3657257 100644 --- a/app/screens/integration_selector/integration_selector.tsx +++ b/app/screens/integration_selector/integration_selector.tsx @@ -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([]); // Channels and DialogOptions, will be removed - const [multiselectSelected, setMultiselectSelected] = useState({}); + const [multiselectSelected, setMultiselectSelected] = useState(() => { + 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) { diff --git a/app/screens/login/form.tsx b/app/screens/login/form.tsx index 19e9f2f08..8dda0531d 100644 --- a/app/screens/login/form.tsx +++ b/app/screens/login/form.tsx @@ -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 ( ({ 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 ( - + - - { 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(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(initialOOOMsg); const styles = getStyleSheet(theme); @@ -97,42 +88,34 @@ const NotificationAutoResponder = ({currentUser, componentId}: NotificationAutoR return ( - - - {autoResponderActive && ( - + - )} - + + {autoResponderActive && ( + + )} + + ); }; diff --git a/app/screens/settings/notification_mention/mention_settings.tsx b/app/screens/settings/notification_mention/mention_settings.tsx index f45e25aba..e88f7c520 100644 --- a/app/screens/settings/notification_mention/mention_settings.tsx +++ b/app/screens/settings/notification_mention/mention_settings.tsx @@ -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' /> - + + + { + 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. diff --git a/share_extension/components/content_view/message/index.tsx b/share_extension/components/content_view/message/index.tsx index e03303e08..d341fc027 100644 --- a/share_extension/components/content_view/message/index.tsx +++ b/share_extension/components/content_view/message/index.tsx @@ -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 ( );