From 7282434e69d9d0a4ef1d87e4ee822ee97e64a424 Mon Sep 17 00:00:00 2001 From: xmflsct Date: Sun, 18 Sep 2022 23:28:14 +0200 Subject: [PATCH] Fix emoji state --- src/components/Emojis.tsx | 40 ++- src/components/Emojis/Button.tsx | 13 +- src/components/Emojis/List.tsx | 331 +++++++++--------- .../Emojis/helpers/EmojisContext.tsx | 18 +- src/components/Input.tsx | 43 ++- src/screens/Tabs/Me/Profile/Fields.tsx | 29 +- src/screens/Tabs/Me/Profile/Name.tsx | 27 +- src/screens/Tabs/Me/Profile/Note.tsx | 14 +- 8 files changed, 265 insertions(+), 250 deletions(-) diff --git a/src/components/Emojis.tsx b/src/components/Emojis.tsx index a053820f..a6393082 100644 --- a/src/components/Emojis.tsx +++ b/src/components/Emojis.tsx @@ -1,15 +1,15 @@ import EmojisButton from '@components/Emojis/Button' import EmojisList from '@components/Emojis/List' +import { PasteInputRef } from '@mattermost/react-native-paste-input' import { useAccessibility } from '@utils/accessibility/AccessibilityManager' import { useEmojisQuery } from '@utils/queryHooks/emojis' import { getInstanceFrequentEmojis } from '@utils/slices/instancesSlice' import { chunk, forEach, groupBy, sortBy } from 'lodash' import React, { PropsWithChildren, RefObject, useEffect, useReducer, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Keyboard, KeyboardAvoidingView, TextInput, View } from 'react-native' +import { Keyboard, KeyboardAvoidingView, Platform, TextInput, View } from 'react-native' import FastImage from 'react-native-fast-image' -import { ScrollView } from 'react-native-gesture-handler' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' +import { Edge, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useSelector } from 'react-redux' import EmojisContext, { emojisReducer, EmojisState } from './Emojis/helpers/EmojisContext' @@ -42,20 +42,26 @@ const prefetchEmojis = ( export type Props = { inputProps: EmojisState['inputProps'] - focusRef?: RefObject + focusRef?: RefObject + customButton?: boolean + customEdges?: Edge[] + customBehavior?: 'height' | 'padding' | 'position' } const ComponentEmojis: React.FC = ({ children, inputProps, - focusRef + focusRef, + customButton = false, + customEdges = ['bottom'], + customBehavior }) => { const { reduceMotionEnabled } = useAccessibility() const [emojisState, emojisDispatch] = useReducer(emojisReducer, { emojis: [], - targetProps: null, - inputProps + inputProps, + targetIndex: -1 }) useEffect(() => { emojisDispatch({ type: 'input', payload: inputProps }) @@ -89,9 +95,9 @@ const ComponentEmojis: React.FC = ({ const [keyboardShown, setKeyboardShown] = useState(false) useEffect(() => { const showSubscription = Keyboard.addListener('keyboardWillShow', () => { - const anyInputHasFocus = inputProps.filter(props => props.ref.current?.isFocused()).length + const anyInputHasFocus = inputProps.filter(props => props.isFocused.current).length if (anyInputHasFocus) { - emojisDispatch({ type: 'target', payload: null }) + emojisDispatch({ type: 'target', payload: -1 }) } setKeyboardShown(true) }) @@ -111,17 +117,23 @@ const ComponentEmojis: React.FC = ({ }, []) return ( - - - + + + - + {children} : } + children={ + emojisState.targetIndex !== -1 ? ( + + ) : customButton ? null : ( + + ) + } /> diff --git a/src/components/Emojis/Button.tsx b/src/components/Emojis/Button.tsx index 6ccc2af8..3b7be527 100644 --- a/src/components/Emojis/Button.tsx +++ b/src/components/Emojis/Button.tsx @@ -9,18 +9,19 @@ const EmojisButton: React.FC = () => { const { colors } = useTheme() const { emojisState, emojisDispatch } = useContext(EmojisContext) + const focusedPropsIndex = emojisState.inputProps?.findIndex(props => props.isFocused.current) + if (focusedPropsIndex === -1) { + return null + } + return ( { - const targetProps = emojisState.inputProps?.find(props => props.ref.current?.isFocused()) - if (!targetProps) { - return - } - if (emojisState.targetProps === null) { + if (emojisState.targetIndex === -1) { Keyboard.dismiss() } - emojisDispatch({ type: 'target', payload: targetProps }) + emojisDispatch({ type: 'target', payload: focusedPropsIndex }) }} hitSlop={StyleConstants.Spacing.S} style={{ diff --git a/src/components/Emojis/List.tsx b/src/components/Emojis/List.tsx index 3c0c838c..9f5aeb14 100644 --- a/src/components/Emojis/List.tsx +++ b/src/components/Emojis/List.tsx @@ -7,7 +7,7 @@ import { StyleConstants } from '@utils/styles/constants' import layoutAnimation from '@utils/styles/layoutAnimation' import { useTheme } from '@utils/styles/ThemeManager' import { chunk } from 'lodash' -import React, { useCallback, useContext, useEffect, useRef, useState } from 'react' +import React, { useContext, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { AccessibilityInfo, @@ -21,191 +21,188 @@ import FastImage from 'react-native-fast-image' import validUrl from 'valid-url' import EmojisContext from './helpers/EmojisContext' -const EmojisList = React.memo( - () => { - const dispatch = useAppDispatch() - const { reduceMotionEnabled } = useAccessibility() - const { t } = useTranslation() +const EmojisList = () => { + const dispatch = useAppDispatch() + const { reduceMotionEnabled } = useAccessibility() + const { t } = useTranslation() - const { emojisState } = useContext(EmojisContext) - const { colors, mode } = useTheme() + const { emojisState } = useContext(EmojisContext) + const { colors, mode } = useTheme() - const addEmoji = (shortcode: string) => { - if (!emojisState.targetProps) { - return - } - - const inputValue = emojisState.targetProps.value - const maxLength = emojisState.targetProps.maxLength - const selectionRange = emojisState.targetProps.selectionRange || { - start: emojisState.targetProps.value.length, - end: emojisState.targetProps.value.length - } - - if (inputValue?.length) { - const contentFront = inputValue.slice(0, selectionRange.start) - const contentRear = inputValue.slice(selectionRange.end) - - const whiteSpaceRear = /\s/g.test(contentRear.slice(-1)) - - const newTextWithSpace = ` ${shortcode}${whiteSpaceRear ? '' : ' '}` - emojisState.targetProps.setValue( - [contentFront, newTextWithSpace, contentRear].join('').slice(0, maxLength) - ) - } else { - emojisState.targetProps.setValue(`${shortcode} `.slice(0, maxLength)) - } + const addEmoji = (shortcode: string) => { + if (emojisState.targetIndex === -1) { + return } - const listItem = useCallback( - ({ index, item }: { item: Mastodon.Emoji[]; index: number }) => { - return ( - - {item.map(emoji => { - const uri = reduceMotionEnabled ? emoji.static_url : emoji.url - if (validUrl.isHttpsUri(uri)) { - return ( - { - addEmoji(`:${emoji.shortcode}:`) - dispatch(countInstanceEmoji(emoji)) - }} - style={{ padding: StyleConstants.Spacing.S }} - > - - - ) - } else { - return null - } - })} - - ) - }, - [emojisState.targetProps] + const { + value: [value, setValue], + selection: [selection, setSelection], + ref, + maxLength + } = emojisState.inputProps[emojisState.targetIndex] + + const contentFront = value.slice(0, selection.start) + const contentRear = value.slice(selection.end) + + const spaceFront = /\s/g.test(contentFront.slice(-1)) ? '' : ' ' + const spaceRear = /\s/g.test(contentRear[0]) ? '' : ' ' + + setValue( + [contentFront, spaceFront, shortcode, spaceRear, contentRear].join('').slice(0, maxLength) ) - const listRef = useRef(null) - useEffect(() => { - const tagEmojis = findNodeHandle(listRef.current) - if (emojisState.targetProps) { - layoutAnimation() - tagEmojis && AccessibilityInfo.setAccessibilityFocus(tagEmojis) - } - }, [emojisState.targetProps]) + const addedLength = spaceFront.length + shortcode.length + spaceRear.length - const [search, setSearch] = useState('') - const searchLength = useRef(0) - useEffect(() => { - if ( - (search.length === 0 && searchLength.current === 1) || - (search.length === 1 && searchLength.current === 0) - ) { - layoutAnimation() - } - searchLength.current = search.length - }, [search.length, searchLength.current]) + setSelection({ start: selection.start + addedLength }) + ref?.current?.setNativeProps({ + selection: { start: selection.start + addedLength } + }) + } - return emojisState.targetProps ? ( + const listItem = ({ index, item }: { item: Mastodon.Emoji[]; index: number }) => { + return ( + + {item.map(emoji => { + const uri = reduceMotionEnabled ? emoji.static_url : emoji.url + if (validUrl.isHttpsUri(uri)) { + return ( + { + addEmoji(`:${emoji.shortcode}:`) + dispatch(countInstanceEmoji(emoji)) + }} + style={{ padding: StyleConstants.Spacing.S }} + > + + + ) + } else { + return null + } + })} + + ) + } + + const listRef = useRef(null) + useEffect(() => { + const tagEmojis = findNodeHandle(listRef.current) + if (emojisState.targetIndex !== -1) { + layoutAnimation() + tagEmojis && AccessibilityInfo.setAccessibilityFocus(tagEmojis) + } + }, [emojisState.targetIndex]) + + const [search, setSearch] = useState('') + const searchLength = useRef(0) + useEffect(() => { + if ( + (search.length === 0 && searchLength.current === 1) || + (search.length === 1 && searchLength.current === 0) + ) { + layoutAnimation() + } + searchLength.current = search.length + }, [search.length, searchLength.current]) + + return emojisState.targetIndex !== -1 ? ( + - - - - + - e.type !== 'frequent') - .flatMap(e => - e.data.flatMap(e => e).filter(emoji => emoji.shortcode.includes(search)) - ), - 2 - ) - } - ] - : emojisState.emojis - } - keyExtractor={item => item[0]?.shortcode} - renderSectionHeader={({ section: { title } }) => ( - - {title} - - )} - renderItem={listItem} - windowSize={4} - contentContainerStyle={{ - paddingHorizontal: StyleConstants.Spacing.Global.PagePadding, - minHeight: 32 * 2 + StyleConstants.Spacing.M * 3 + - ) : null - }, - () => true -) + e.type !== 'frequent') + .flatMap(e => + e.data.flatMap(e => e).filter(emoji => emoji.shortcode.includes(search)) + ), + 2 + ) + } + ] + : emojisState.emojis + } + keyExtractor={item => item[0]?.shortcode} + renderSectionHeader={({ section: { title } }) => ( + + {title} + + )} + renderItem={listItem} + windowSize={4} + contentContainerStyle={{ + paddingHorizontal: StyleConstants.Spacing.Global.PagePadding, + minHeight: 32 * 2 + StyleConstants.Spacing.M * 3 + }} + /> + + ) : null +} export default EmojisList diff --git a/src/components/Emojis/helpers/EmojisContext.tsx b/src/components/Emojis/helpers/EmojisContext.tsx index b7afe195..f821ce03 100644 --- a/src/components/Emojis/helpers/EmojisContext.tsx +++ b/src/components/Emojis/helpers/EmojisContext.tsx @@ -1,11 +1,11 @@ -import { createContext, Dispatch, RefObject, SetStateAction } from 'react' +import { createContext, Dispatch, MutableRefObject, RefObject } from 'react' import { TextInput } from 'react-native' type inputProps = { - ref: RefObject - value: string - setValue: Dispatch> - selectionRange?: { start: number; end: number } + value: [string, (value: string) => void] + selection: [{ start: number; end?: number }, (selection: { start: number; end?: number }) => void] + isFocused: MutableRefObject + ref?: RefObject // For controlling focus maxLength?: number } @@ -15,14 +15,14 @@ export type EmojisState = { data: Pick[][] type?: 'frequent' }[] - targetProps: inputProps | null inputProps: inputProps[] + targetIndex: number } export type EmojisAction = | { type: 'load'; payload: NonNullable } - | { type: 'target'; payload: EmojisState['targetProps'] } | { type: 'input'; payload: EmojisState['inputProps'] } + | { type: 'target'; payload: EmojisState['targetIndex'] } type ContextType = { emojisState: EmojisState @@ -34,10 +34,10 @@ export const emojisReducer = (state: EmojisState, action: EmojisAction) => { switch (action.type) { case 'load': return { ...state, emojis: action.payload } - case 'target': - return { ...state, targetProps: action.payload } case 'input': return { ...state, inputProps: action.payload } + case 'target': + return { ...state, targetIndex: action.payload } } } diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 0ba57800..7354dc44 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -1,30 +1,37 @@ import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' -import React, { Dispatch, forwardRef, RefObject, SetStateAction, useRef } from 'react' +import React, { forwardRef, RefObject } from 'react' import { Platform, TextInput, TextInputProps, View } from 'react-native' import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' +import { EmojisState } from './Emojis/helpers/EmojisContext' import CustomText from './Text' export type Props = { - value: string - setValue: Dispatch> - selectionRange?: { start: number; end: number } - title?: string multiline?: boolean -} & Omit< - TextInputProps, - | 'style' - | 'onChangeText' - | 'onSelectionChange' - | 'keyboardAppearance' - | 'textAlignVertical' - | 'multiline' -> +} & Pick, 'value' | 'selection' | 'isFocused'> & + Omit< + TextInputProps, + | 'style' + | 'onChangeText' + | 'onSelectionChange' + | 'keyboardAppearance' + | 'textAlignVertical' + | 'multiline' + | 'selection' + | 'value' + > const ComponentInput = forwardRef( ( - { title, multiline = false, value, setValue, selectionRange, ...props }: Props, + { + title, + multiline = false, + value: [value, setValue], + selection: [selection, setSelection], + isFocused, + ...props + }: Props, ref: RefObject ) => { const { colors, mode } = useTheme() @@ -69,9 +76,11 @@ const ComponentInput = forwardRef( minHeight: Platform.OS === 'ios' && multiline ? StyleConstants.Font.LineHeight.M * 5 : undefined }} - onChangeText={setValue} - onSelectionChange={({ nativeEvent: { selection } }) => (selectionRange = selection)} value={value} + onChangeText={setValue} + onFocus={() => (isFocused.current = true)} + onBlur={() => (isFocused.current = false)} + onSelectionChange={({ nativeEvent }) => setSelection(nativeEvent.selection)} {...(multiline && { multiline, numberOfLines: Platform.OS === 'android' ? 5 : undefined diff --git a/src/screens/Tabs/Me/Profile/Fields.tsx b/src/screens/Tabs/Me/Profile/Fields.tsx index 58eea69b..ed115bc5 100644 --- a/src/screens/Tabs/Me/Profile/Fields.tsx +++ b/src/screens/Tabs/Me/Profile/Fields.tsx @@ -7,10 +7,9 @@ import { TabMeProfileStackScreenProps } from '@utils/navigation/navigators' import { useProfileMutation } from '@utils/queryHooks/profile' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' -import { isEqual } from 'lodash' import React, { Dispatch, RefObject, SetStateAction, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Alert, TextInput, View } from 'react-native' +import { Alert, ScrollView } from 'react-native' import FlashMessage from 'react-native-flash-message' const Field: React.FC<{ @@ -25,23 +24,21 @@ const Field: React.FC<{ const [name, setName] = useState(field?.name || '') const [value, setValue] = useState(field?.value || '') allProps[index * 2] = { - ref: useRef(null), - value: name, - setValue: setName, - selectionRange: name ? { start: name.length, end: name.length } : { start: 0, end: 0 }, + value: [name, setName], + selection: useState({ start: name.length }), + isFocused: useRef(false), maxLength: 255 } allProps[index * 2 + 1] = { - ref: useRef(null), - value, - setValue, - selectionRange: value ? { start: value.length, end: value.length } : { start: 0, end: 0 }, + value: [value, setValue], + selection: useState({ start: value.length }), + isFocused: useRef(false), maxLength: 255 } useEffect(() => { setDirty(dirty => - dirty ? dirty : !isEqual(field?.name, name) || !isEqual(field?.value, value) + dirty ? dirty : (field?.name || '') !== name || (field?.value || '') !== value ) }, [name, value]) @@ -130,11 +127,11 @@ const TabMeProfileFields: React.FC< data: Array.from(Array(4).keys()) .filter( index => - allProps[index * 2]?.value.length || allProps[index * 2 + 1]?.value.length + allProps[index * 2]?.value[0].length || allProps[index * 2 + 1]?.value[0].length ) .map(index => ({ - name: allProps[index * 2].value, - value: allProps[index * 2 + 1].value + name: allProps[index * 2].value[0], + value: allProps[index * 2 + 1].value[0] })) }).then(() => { navigation.navigate('Tab-Me-Profile-Root') @@ -147,7 +144,7 @@ const TabMeProfileFields: React.FC< return ( - + {Array.from(Array(4).keys()).map(index => ( ))} - + ) } diff --git a/src/screens/Tabs/Me/Profile/Name.tsx b/src/screens/Tabs/Me/Profile/Name.tsx index 736e7b35..4485e93f 100644 --- a/src/screens/Tabs/Me/Profile/Name.tsx +++ b/src/screens/Tabs/Me/Profile/Name.tsx @@ -8,7 +8,7 @@ import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import React, { RefObject, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Alert, TextInput, View } from 'react-native' +import { Alert, ScrollView, TextInput } from 'react-native' import FlashMessage from 'react-native-flash-message' const TabMeProfileName: React.FC< @@ -26,21 +26,20 @@ const TabMeProfileName: React.FC< const { t, i18n } = useTranslation('screenTabs') const { mutateAsync, status } = useProfileMutation() - const [displayName, setDisplayName] = useState(display_name) - const displayNameProps: NonNullable = { + const [value, setValue] = useState(display_name) + const displayNameProps: NonNullable = { + value: [value, setValue], + selection: useState({ start: value.length }), + isFocused: useRef(false), ref: useRef(null), - value: displayName, - setValue: setDisplayName, - selectionRange: displayName - ? { start: displayName.length, end: displayName.length } - : { start: 0, end: 0 }, maxLength: 30 } + console.log('true value', value) const [dirty, setDirty] = useState(false) useEffect(() => { - setDirty(display_name !== displayName) - }, [displayName]) + setDirty(display_name !== value) + }, [value]) useEffect(() => { navigation.setOptions({ @@ -84,7 +83,7 @@ const TabMeProfileName: React.FC< failed: true }, type: 'display_name', - data: displayName + data: value }).then(() => { navigation.navigate('Tab-Me-Profile-Root') }) @@ -92,11 +91,11 @@ const TabMeProfileName: React.FC< /> ) }) - }, [theme, i18n.language, dirty, status, displayName]) + }, [theme, i18n.language, dirty, status, value]) return ( - + - + ) } diff --git a/src/screens/Tabs/Me/Profile/Note.tsx b/src/screens/Tabs/Me/Profile/Note.tsx index 8a1a75bb..56dba987 100644 --- a/src/screens/Tabs/Me/Profile/Note.tsx +++ b/src/screens/Tabs/Me/Profile/Note.tsx @@ -8,7 +8,7 @@ import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import React, { RefObject, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Alert, TextInput, View } from 'react-native' +import { Alert, ScrollView, TextInput } from 'react-native' import FlashMessage from 'react-native-flash-message' const TabMeProfileNote: React.FC< @@ -27,11 +27,11 @@ const TabMeProfileNote: React.FC< const { mutateAsync, status } = useProfileMutation() const [notes, setNotes] = useState(note) - const notesProps: NonNullable = { + const notesProps: NonNullable = { + value: [notes, setNotes], + selection: useState({ start: notes.length }), + isFocused: useRef(false), ref: useRef(null), - value: notes, - setValue: setNotes, - selectionRange: notes ? { start: notes.length, end: notes.length } : { start: 0, end: 0 }, maxLength: 500 } @@ -94,9 +94,9 @@ const TabMeProfileNote: React.FC< return ( - + - + ) }