diff --git a/native/account/registration/keyserver-selection.react.js b/native/account/registration/keyserver-selection.react.js index 8592771c5..f467f1978 100644 --- a/native/account/registration/keyserver-selection.react.js +++ b/native/account/registration/keyserver-selection.react.js @@ -1,268 +1,269 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; -import { Text, View } from 'react-native'; +import { Text, View, TextInput } from 'react-native'; import { useGetVersion, getVersionActionTypes, } from 'lib/actions/device-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import RegistrationButtonContainer from './registration-button-container.react.js'; import RegistrationButton from './registration-button.react.js'; import RegistrationContainer from './registration-container.react.js'; import RegistrationContentContainer from './registration-content-container.react.js'; import { RegistrationContext } from './registration-context.js'; import type { RegistrationNavigationProp } from './registration-navigator.react.js'; import RegistrationTextInput from './registration-text-input.react.js'; import { RegistrationTile, RegistrationTileHeader, } from './registration-tile.react.js'; import type { CoolOrNerdMode } from './registration-types.js'; import CommIcon from '../../components/comm-icon.react.js'; import { type NavigationRoute, ConnectEthereumRouteName, } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles, useColors } from '../../themes/colors.js'; import { defaultURLPrefix } from '../../utils/url-utils.js'; type Selection = 'ashoat' | 'custom'; export type KeyserverSelectionParams = { +userSelections: { +coolOrNerdMode: CoolOrNerdMode, }, }; const getVersionLoadingStatusSelector = createLoadingStatusSelector( getVersionActionTypes, ); type KeyserverSelectionError = 'cant_reach_keyserver'; type Props = { +navigation: RegistrationNavigationProp<'KeyserverSelection'>, +route: NavigationRoute<'KeyserverSelection'>, }; function KeyserverSelection(props: Props): React.Node { const registrationContext = React.useContext(RegistrationContext); invariant(registrationContext, 'registrationContext should be set'); const { cachedSelections, setCachedSelections } = registrationContext; const initialKeyserverURL = cachedSelections.keyserverURL; const [customKeyserver, setCustomKeyserver] = React.useState( initialKeyserverURL === defaultURLPrefix ? '' : initialKeyserverURL, ); - const customKeyserverTextInputRef = React.useRef(); + const customKeyserverTextInputRef = + React.useRef>(); let initialSelection; if (initialKeyserverURL === defaultURLPrefix) { initialSelection = 'ashoat'; } else if (initialKeyserverURL) { initialSelection = 'custom'; } const [error, setError] = React.useState(); const [currentSelection, setCurrentSelection] = React.useState(initialSelection); const selectAshoat = React.useCallback(() => { setCurrentSelection('ashoat'); customKeyserverTextInputRef.current?.blur(); if (currentSelection !== 'ashoat') { setError(undefined); } }, [currentSelection]); const customKeyserverEmpty = !customKeyserver; const selectCustom = React.useCallback(() => { setCurrentSelection('custom'); if (customKeyserverEmpty) { customKeyserverTextInputRef.current?.focus(); } if (currentSelection !== 'custom') { setError(undefined); } }, [customKeyserverEmpty, currentSelection]); const onCustomKeyserverFocus = React.useCallback(() => { setCurrentSelection('custom'); setError(undefined); }, []); let keyserverURL; if (currentSelection === 'ashoat') { keyserverURL = defaultURLPrefix; } else if (currentSelection === 'custom' && customKeyserver) { keyserverURL = customKeyserver; } const versionLoadingStatus = useSelector(getVersionLoadingStatusSelector); let buttonState = keyserverURL ? 'enabled' : 'disabled'; if (versionLoadingStatus === 'loading') { buttonState = 'loading'; } const serverCallParamOverride = React.useMemo( () => ({ keyserverInfos: { [keyserverURL]: { urlPrefix: keyserverURL, }, }, }), [keyserverURL], ); const getVersionCall = useGetVersion(serverCallParamOverride); const dispatchActionPromise = useDispatchActionPromise(); const { navigate } = props.navigation; const { coolOrNerdMode } = props.route.params.userSelections; const onSubmit = React.useCallback(async () => { setError(undefined); if (!keyserverURL) { return; } const getVersionPromise = getVersionCall(); dispatchActionPromise(getVersionActionTypes, getVersionPromise); // We don't care about the result; just need to make sure this doesn't throw try { await getVersionPromise; } catch { setError('cant_reach_keyserver'); return; } setCachedSelections(oldUserSelections => ({ ...oldUserSelections, keyserverURL, })); navigate<'ConnectEthereum'>({ name: ConnectEthereumRouteName, params: { userSelections: { coolOrNerdMode, keyserverURL } }, }); }, [ navigate, coolOrNerdMode, keyserverURL, setCachedSelections, dispatchActionPromise, getVersionCall, ]); const styles = useStyles(unboundStyles); let errorText; if (error === 'cant_reach_keyserver') { errorText = ( Can’t reach that keyserver :( ); } const colors = useColors(); return ( Select a keyserver to join Chat communities on Comm are hosted on keyservers, which are user-operated backends. Keyservers allow Comm to offer strong privacy guarantees without sacrificing functionality. ashoat Ashoat is Comm’s founder, and his keyserver currently hosts most of the communities on Comm. Enter a keyserver {errorText} ); } const unboundStyles = { header: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 16, }, body: { fontFamily: 'Arial', fontSize: 15, lineHeight: 20, color: 'panelForegroundSecondaryLabel', paddingBottom: 16, }, tileTitleText: { flex: 1, fontSize: 18, color: 'panelForegroundLabel', }, tileBody: { fontFamily: 'Arial', fontSize: 13, color: 'panelForegroundSecondaryLabel', }, cloud: { marginRight: 8, }, error: { marginTop: 16, }, errorText: { fontFamily: 'Arial', fontSize: 15, lineHeight: 20, color: 'redText', }, }; export default KeyserverSelection; diff --git a/native/account/registration/password-selection.react.js b/native/account/registration/password-selection.react.js index 62e37bfb9..72472eaef 100644 --- a/native/account/registration/password-selection.react.js +++ b/native/account/registration/password-selection.react.js @@ -1,248 +1,249 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; -import { View, Text, Platform } from 'react-native'; +import { View, Text, Platform, TextInput } from 'react-native'; import sleep from 'lib/utils/sleep.js'; import RegistrationButtonContainer from './registration-button-container.react.js'; import RegistrationButton from './registration-button.react.js'; import RegistrationContainer from './registration-container.react.js'; import RegistrationContentContainer from './registration-content-container.react.js'; import { RegistrationContext } from './registration-context.js'; import type { RegistrationNavigationProp } from './registration-navigator.react.js'; import RegistrationTextInput from './registration-text-input.react.js'; import type { CoolOrNerdMode } from './registration-types.js'; import { type NavigationRoute, AvatarSelectionRouteName, } from '../../navigation/route-names.js'; import { useStyles } from '../../themes/colors.js'; import type { KeyPressEvent } from '../../types/react-native.js'; export type PasswordSelectionParams = { +userSelections: { +coolOrNerdMode: CoolOrNerdMode, +keyserverURL: string, +username: string, }, }; type PasswordError = 'passwords_dont_match' | 'empty_password'; type Props = { +navigation: RegistrationNavigationProp<'PasswordSelection'>, +route: NavigationRoute<'PasswordSelection'>, }; function PasswordSelection(props: Props): React.Node { const registrationContext = React.useContext(RegistrationContext); invariant(registrationContext, 'registrationContext should be set'); const { cachedSelections, setCachedSelections } = registrationContext; const [password, setPassword] = React.useState( cachedSelections.password ?? '', ); const [confirmPassword, setConfirmPassword] = React.useState( cachedSelections.password ?? '', ); const passwordsMatch = password === confirmPassword; const passwordIsEmpty = password === ''; const [passwordError, setPasswordError] = React.useState(); const potentiallyClearErrors = React.useCallback(() => { if (!passwordsMatch || passwordIsEmpty) { return false; } setPasswordError(null); return true; }, [passwordsMatch, passwordIsEmpty]); const checkPasswordValidity = React.useCallback(() => { if (!passwordsMatch) { setPasswordError('passwords_dont_match'); return false; } else if (passwordIsEmpty) { setPasswordError('empty_password'); return false; } return potentiallyClearErrors(); }, [passwordsMatch, passwordIsEmpty, potentiallyClearErrors]); const { userSelections } = props.route.params; const { navigate } = props.navigation; const onProceed = React.useCallback(() => { if (!checkPasswordValidity()) { return; } const { coolOrNerdMode, keyserverURL, username } = userSelections; const newUserSelections = { coolOrNerdMode, keyserverURL, accountSelection: { accountType: 'username', username, password, }, }; setCachedSelections(oldUserSelections => ({ ...oldUserSelections, password, })); navigate<'AvatarSelection'>({ name: AvatarSelectionRouteName, params: { userSelections: newUserSelections }, }); }, [ checkPasswordValidity, userSelections, password, setCachedSelections, navigate, ]); const styles = useStyles(unboundStyles); let errorText; if (passwordError === 'passwords_dont_match') { errorText = ( Passwords don’t match ); } else if (passwordError === 'empty_password') { errorText = Password cannot be empty; } - const confirmPasswordInputRef = React.useRef(); + const confirmPasswordInputRef = + React.useRef>(); const focusConfirmPasswordInput = React.useCallback(() => { confirmPasswordInputRef.current?.focus(); }, []); const iosPasswordBeingAutoFilled = React.useRef(false); const confirmPasswordEmpty = confirmPassword.length === 0; const onPasswordKeyPress = React.useCallback( (event: KeyPressEvent) => { const { key } = event.nativeEvent; // On iOS, paste doesn't trigger onKeyPress, but password autofill does // Password autofill calls onKeyPress with `key` set to the whole password if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && confirmPasswordEmpty ) { iosPasswordBeingAutoFilled.current = true; } }, [confirmPasswordEmpty], ); - const passwordInputRef = React.useRef(); + const passwordInputRef = React.useRef>(); const passwordLength = password.length; const onChangePasswordInput = React.useCallback( (input: string) => { setPassword(input); if (iosPasswordBeingAutoFilled.current) { // On iOS, paste doesn't trigger onKeyPress, but password autofill does iosPasswordBeingAutoFilled.current = false; setConfirmPassword(input); passwordInputRef.current?.blur(); } else if ( Platform.OS === 'android' && input.length - passwordLength > 1 && confirmPasswordEmpty ) { // On Android, password autofill doesn't trigger onKeyPress. Instead we // rely on observing when the password field changes by more than one // character at a time. This means we treat paste the same way as // password autofill setConfirmPassword(input); passwordInputRef.current?.blur(); } }, [passwordLength, confirmPasswordEmpty], ); const shouldAutoFocus = React.useRef(!cachedSelections.password); /* eslint-disable react-hooks/rules-of-hooks */ if (Platform.OS === 'android') { // It's okay to call this hook conditionally because // the condition is guaranteed to never change React.useEffect(() => { (async () => { await sleep(250); if (shouldAutoFocus.current) { passwordInputRef.current?.focus(); } })(); }, []); } /* eslint-enable react-hooks/rules-of-hooks */ const autoFocus = Platform.OS !== 'android' && shouldAutoFocus.current; return ( Pick a password {errorText} ); } const unboundStyles = { header: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 16, }, error: { marginTop: 16, }, errorText: { fontFamily: 'Arial', fontSize: 15, lineHeight: 20, color: 'redText', }, confirmPassword: { marginTop: 16, }, }; export default PasswordSelection; diff --git a/native/account/siwe-panel.react.js b/native/account/siwe-panel.react.js index 43ff2acbe..a64b5dd67 100644 --- a/native/account/siwe-panel.react.js +++ b/native/account/siwe-panel.react.js @@ -1,227 +1,227 @@ // @flow import BottomSheet from '@gorhom/bottom-sheet'; import * as React from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import WebView from 'react-native-webview'; import { getSIWENonce, getSIWENonceActionTypes, siweAuthActionTypes, } from 'lib/actions/siwe-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { SIWEWebViewMessage, SIWEResult } from 'lib/types/siwe-types.js'; import { useServerCall, useDispatchActionPromise, type BindServerCallsParams, } from 'lib/utils/action-utils.js'; import { useKeyboardHeight } from '../keyboard/keyboard-hooks.js'; import { useSelector } from '../redux/redux-utils.js'; import Alert from '../utils/alert.js'; import { getContentSigningKey } from '../utils/crypto-utils.js'; import { defaultLandingURLPrefix } from '../utils/url-utils.js'; const commSIWE = `${defaultLandingURLPrefix}/siwe`; const getSIWENonceLoadingStatusSelector = createLoadingStatusSelector( getSIWENonceActionTypes, ); const siweAuthLoadingStatusSelector = createLoadingStatusSelector(siweAuthActionTypes); type WebViewMessageEvent = { +nativeEvent: { +data: string, ... }, ... }; type Props = { +onClosed: () => mixed, +onClosing: () => mixed, +onSuccessfulWalletSignature: SIWEResult => mixed, +closing: boolean, +setLoading: boolean => mixed, +keyserverCallParamOverride?: $Shape, }; function SIWEPanel(props: Props): React.Node { const dispatchActionPromise = useDispatchActionPromise(); const getSIWENonceCall = useServerCall( getSIWENonce, props.keyserverCallParamOverride, ); const getSIWENonceCallFailed = useSelector( state => getSIWENonceLoadingStatusSelector(state) === 'error', ); const { onClosing } = props; React.useEffect(() => { if (getSIWENonceCallFailed) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onClosing }], { cancelable: false }, ); } }, [getSIWENonceCallFailed, onClosing]); const siweAuthCallLoading = useSelector( state => siweAuthLoadingStatusSelector(state) === 'loading', ); const [nonce, setNonce] = React.useState(null); const [primaryIdentityPublicKey, setPrimaryIdentityPublicKey] = React.useState(null); React.useEffect(() => { (async () => { dispatchActionPromise( getSIWENonceActionTypes, (async () => { const response = await getSIWENonceCall(); setNonce(response); })(), ); const ed25519 = await getContentSigningKey(); setPrimaryIdentityPublicKey(ed25519); })(); }, [dispatchActionPromise, getSIWENonceCall]); const [isLoading, setLoading] = React.useState(true); const [walletConnectModalHeight, setWalletConnectModalHeight] = React.useState(0); const insets = useSafeAreaInsets(); const keyboardHeight = useKeyboardHeight(); const bottomInset = insets.bottom; const snapPoints = React.useMemo(() => { if (isLoading) { return [1]; } else if (walletConnectModalHeight) { const baseHeight = bottomInset + walletConnectModalHeight + keyboardHeight; if (baseHeight < 400) { return [baseHeight - 10]; } else { return [baseHeight + 5]; } } else { const baseHeight = bottomInset + keyboardHeight; return [baseHeight + 435, baseHeight + 600]; } }, [isLoading, walletConnectModalHeight, bottomInset, keyboardHeight]); const bottomSheetRef = React.useRef(); const snapToIndex = bottomSheetRef.current?.snapToIndex; React.useEffect(() => { // When the snapPoints change, always reset to the first one // Without this, when we close the WalletConnect modal we don't resize snapToIndex?.(0); }, [snapToIndex, snapPoints]); const closeBottomSheet = bottomSheetRef.current?.close; const { closing, onSuccessfulWalletSignature } = props; const handleMessage = React.useCallback( async (event: WebViewMessageEvent) => { const data: SIWEWebViewMessage = JSON.parse(event.nativeEvent.data); if (data.type === 'siwe_success') { const { address, message, signature } = data; if (address && signature) { closeBottomSheet?.(); await onSuccessfulWalletSignature({ address, message, signature }); } } else if (data.type === 'siwe_closed') { onClosing(); closeBottomSheet?.(); } else if (data.type === 'walletconnect_modal_update') { const height = data.state === 'open' ? data.height : 0; setWalletConnectModalHeight(height); } }, [onSuccessfulWalletSignature, onClosing, closeBottomSheet], ); - const prevClosingRef = React.useRef(); + const prevClosingRef = React.useRef(); React.useEffect(() => { if (closing && !prevClosingRef.current) { closeBottomSheet?.(); } prevClosingRef.current = closing; }, [closing, closeBottomSheet]); const source = React.useMemo( () => ({ uri: commSIWE, headers: { 'siwe-nonce': nonce, 'siwe-primary-identity-public-key': primaryIdentityPublicKey, }, }), [nonce, primaryIdentityPublicKey], ); const onWebViewLoaded = React.useCallback(() => { setLoading(false); }, []); const walletConnectModalOpen = walletConnectModalHeight !== 0; const backgroundStyle = React.useMemo( () => ({ backgroundColor: walletConnectModalOpen ? '#3396ff' : '#242529', }), [walletConnectModalOpen], ); const bottomSheetHandleIndicatorStyle = React.useMemo( () => ({ backgroundColor: 'white', }), [], ); const { onClosed } = props; const onBottomSheetChange = React.useCallback( (index: number) => { if (index === -1) { onClosed(); } }, [onClosed], ); let bottomSheet; if (nonce && primaryIdentityPublicKey) { bottomSheet = ( ); } const setLoadingProp = props.setLoading; const loading = !getSIWENonceCallFailed && (isLoading || siweAuthCallLoading); React.useEffect(() => { setLoadingProp(loading); }, [setLoadingProp, loading]); return bottomSheet; } export default SIWEPanel; diff --git a/native/chat/chat-context.js b/native/chat/chat-context.js index cb1da7ce1..27d56a0c3 100644 --- a/native/chat/chat-context.js +++ b/native/chat/chat-context.js @@ -1,54 +1,54 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import type { SetState } from 'lib/types/hook-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import type { NativeChatMessageItem } from './message-data.react.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; export type MessagesMeasurer = ( ?$ReadOnlyArray, ?ThreadInfo | ?MinimallyEncodedThreadInfo, ($ReadOnlyArray) => mixed, ) => void; export type RegisteredMeasurer = { +measure: MessagesMeasurer, +unregister: () => void, }; export type SidebarAnimationType = | 'fade_source_message' | 'move_source_message'; export type ChatContextType = { +registerMeasurer: () => RegisteredMeasurer, +currentTransitionSidebarSourceID: ?string, +setCurrentTransitionSidebarSourceID: SetState, +setChatInputBarHeight: (threadID: string, height: number) => mixed, +deleteChatInputBarHeight: (threadID: string) => mixed, +chatInputBarHeights: $ReadOnlyMap, +sidebarAnimationType: SidebarAnimationType, +setSidebarAnimationType: (animationType: SidebarAnimationType) => mixed, }; const ChatContext: React.Context = React.createContext(null); function useHeightMeasurer(): MessagesMeasurer { const chatContext = React.useContext(ChatContext); invariant(chatContext, 'Chat context should be set'); - const measureRegistrationRef = React.useRef(); + const measureRegistrationRef = React.useRef(); if (!measureRegistrationRef.current) { measureRegistrationRef.current = chatContext.registerMeasurer(); } const measureRegistration = measureRegistrationRef.current; React.useEffect(() => { return measureRegistration.unregister; }, [measureRegistration]); return measureRegistration.measure; } export { ChatContext, useHeightMeasurer }; diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index 06c9ec14a..bb37fcb28 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,496 +1,497 @@ // @flow import IonIcon from '@expo/vector-icons/Ionicons.js'; import type { TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, StackNavigationState, StackOptions, StackNavigationEventMap, } from '@react-navigation/core'; import invariant from 'invariant'; import * as React from 'react'; import { View, FlatList, Platform, TouchableWithoutFeedback, BackHandler, + TextInput, } from 'react-native'; import { FloatingAction } from 'react-native-floating-action'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { type ChatThreadItem, useFlattenedChatListData, } from 'lib/selectors/chat-selectors.js'; import { createPendingThread, getThreadListSearchResults, useThreadListSearch, } from 'lib/shared/thread-utils.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import type { UserInfo } from 'lib/types/user-types.js'; import { ChatThreadListItem } from './chat-thread-list-item.react.js'; import ChatThreadListSearch from './chat-thread-list-search.react.js'; import { getItemLayout, keyExtractor } from './chat-thread-list-utils.js'; import type { ChatTopTabsNavigationProp, ChatNavigationProp, } from './chat.react.js'; import { useNavigateToThread } from './message-list-types.js'; import { SidebarListModalRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, type NavigationRoute, type ScreenParamList, } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { indicatorStyleSelector, useStyles } from '../themes/colors.js'; import type { ScrollEvent } from '../types/react-native.js'; const floatingActions = [ { text: 'Compose', icon: , name: 'compose', position: 1, }, ]; export type Item = | ChatThreadItem | { +type: 'search', +searchText: string } | { +type: 'empty', +emptyItem: React.ComponentType<{}> }; type BaseProps = { +navigation: | ChatTopTabsNavigationProp<'HomeChatThreadList'> | ChatTopTabsNavigationProp<'BackgroundChatThreadList'>, +route: | NavigationRoute<'HomeChatThreadList'> | NavigationRoute<'BackgroundChatThreadList'>, +filterThreads: ( threadItem: ThreadInfo | MinimallyEncodedThreadInfo, ) => boolean, +emptyItem?: React.ComponentType<{}>, }; export type SearchStatus = 'inactive' | 'activating' | 'active'; function ChatThreadList(props: BaseProps): React.Node { const boundChatListData = useFlattenedChatListData(); const loggedInUserInfo = useLoggedInUserInfo(); const styles = useStyles(unboundStyles); const indicatorStyle = useSelector(indicatorStyleSelector); const navigateToThread = useNavigateToThread(); const { navigation, route, filterThreads, emptyItem } = props; const [searchText, setSearchText] = React.useState(''); const [searchStatus, setSearchStatus] = React.useState('inactive'); const { threadSearchResults, usersSearchResults } = useThreadListSearch( searchText, loggedInUserInfo?.id, ); const [openedSwipeableID, setOpenedSwipeableID] = React.useState(''); const [numItemsToDisplay, setNumItemsToDisplay] = React.useState(25); const onChangeSearchText = React.useCallback((updatedSearchText: string) => { setSearchText(updatedSearchText); setNumItemsToDisplay(25); }, []); const scrollPos = React.useRef(0); - const flatListRef = React.useRef(); + const flatListRef = React.useRef>(); const onScroll = React.useCallback( (event: ScrollEvent) => { const oldScrollPos = scrollPos.current; scrollPos.current = event.nativeEvent.contentOffset.y; if (scrollPos.current !== 0 || oldScrollPos === 0) { return; } if (searchStatus === 'activating') { setSearchStatus('active'); } }, [searchStatus], ); const onSwipeableWillOpen = React.useCallback( (threadInfo: ThreadInfo) => setOpenedSwipeableID(threadInfo.id), [], ); const composeThread = React.useCallback(() => { if (!loggedInUserInfo) { return; } const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PRIVATE, members: [loggedInUserInfo], }); navigateToThread({ threadInfo, searching: true }); }, [loggedInUserInfo, navigateToThread]); const onSearchFocus = React.useCallback(() => { if (searchStatus !== 'inactive') { return; } if (scrollPos.current === 0) { setSearchStatus('active'); } else { setSearchStatus('activating'); } }, [searchStatus]); const clearSearch = React.useCallback(() => { if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: false }); } setSearchStatus('inactive'); }, []); const onSearchBlur = React.useCallback(() => { if (searchStatus !== 'active') { return; } clearSearch(); }, [clearSearch, searchStatus]); const onSearchCancel = React.useCallback(() => { onChangeSearchText(''); clearSearch(); }, [clearSearch, onChangeSearchText]); - const searchInputRef = React.useRef(); + const searchInputRef = React.useRef>(); const onPressItem = React.useCallback( (threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo) => { onChangeSearchText(''); if (searchInputRef.current) { searchInputRef.current.blur(); } navigateToThread({ threadInfo, pendingPersonalThreadUserInfo }); }, [navigateToThread, onChangeSearchText], ); const onPressSeeMoreSidebars = React.useCallback( (threadInfo: ThreadInfo) => { onChangeSearchText(''); if (searchInputRef.current) { searchInputRef.current.blur(); } navigation.navigate<'SidebarListModal'>({ name: SidebarListModalRouteName, params: { threadInfo }, }); }, [navigation, onChangeSearchText], ); const hardwareBack = React.useCallback(() => { if (!navigation.isFocused()) { return false; } const isActiveOrActivating = searchStatus === 'active' || searchStatus === 'activating'; if (!isActiveOrActivating) { return false; } onSearchCancel(); return true; }, [navigation, onSearchCancel, searchStatus]); const searchItem = React.useMemo( () => ( ), [ onChangeSearchText, onSearchBlur, onSearchCancel, onSearchFocus, searchStatus, searchText, styles.searchContainer, ], ); const renderItem = React.useCallback( (row: { item: Item, ... }) => { const item = row.item; if (item.type === 'search') { return searchItem; } if (item.type === 'empty') { const EmptyItem = item.emptyItem; return ; } return ( ); }, [ onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, openedSwipeableID, searchItem, ], ); const listData: $ReadOnlyArray = React.useMemo(() => { const chatThreadItems = getThreadListSearchResults( boundChatListData, searchText, filterThreads, threadSearchResults, usersSearchResults, loggedInUserInfo, ); const chatItems: Item[] = [...chatThreadItems]; if (emptyItem && chatItems.length === 0) { chatItems.push({ type: 'empty', emptyItem }); } if (searchStatus === 'inactive' || searchStatus === 'activating') { chatItems.unshift({ type: 'search', searchText }); } return chatItems; }, [ boundChatListData, emptyItem, filterThreads, loggedInUserInfo, searchStatus, searchText, threadSearchResults, usersSearchResults, ]); const partialListData: $ReadOnlyArray = React.useMemo( () => listData.slice(0, numItemsToDisplay), [listData, numItemsToDisplay], ); const onEndReached = React.useCallback(() => { if (partialListData.length === listData.length) { return; } setNumItemsToDisplay(prevNumItems => prevNumItems + 25); }, [listData.length, partialListData.length]); const floatingAction = React.useMemo(() => { if (Platform.OS !== 'android') { return null; } return ( ); }, [composeThread]); const fixedSearch = React.useMemo(() => { if (searchStatus !== 'active') { return null; } return ( ); }, [ onChangeSearchText, onSearchBlur, onSearchCancel, searchStatus, searchText, styles.searchContainer, ]); const scrollEnabled = searchStatus === 'inactive' || searchStatus === 'active'; // viewerID is in extraData since it's used by MessagePreview // within ChatThreadListItem const viewerID = loggedInUserInfo?.id; const extraData = `${viewerID || ''} ${openedSwipeableID}`; const chatThreadList = React.useMemo( () => ( {fixedSearch} {floatingAction} ), [ extraData, fixedSearch, floatingAction, indicatorStyle, onEndReached, onScroll, partialListData, renderItem, scrollEnabled, styles.container, styles.flatList, ], ); const onTabPress = React.useCallback(() => { if (!navigation.isFocused()) { return; } if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } else if (route.name === BackgroundChatThreadListRouteName) { navigation.navigate({ name: HomeChatThreadListRouteName }); } }, [navigation, route.name]); React.useEffect(() => { const clearNavigationBlurListener = navigation.addListener('blur', () => { setNumItemsToDisplay(25); }); return () => { // `.addListener` returns function that can be called to unsubscribe. // https://reactnavigation.org/docs/navigation-events/#navigationaddlistener clearNavigationBlurListener(); }; }, [navigation]); React.useEffect(() => { const chatNavigation = navigation.getParent< ScreenParamList, 'ChatThreadList', StackNavigationState, StackOptions, StackNavigationEventMap, ChatNavigationProp<'ChatThreadList'>, >(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation = chatNavigation.getParent< ScreenParamList, 'Chat', TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, TabNavigationProp<'Chat'>, >(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', onTabPress); return () => { tabNavigation.removeListener('tabPress', onTabPress); }; }, [navigation, onTabPress]); React.useEffect(() => { BackHandler.addEventListener('hardwareBackPress', hardwareBack); return () => { BackHandler.removeEventListener('hardwareBackPress', hardwareBack); }; }, [hardwareBack]); React.useEffect(() => { if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: false }); } }, [searchText]); const isSearchActivating = searchStatus === 'activating'; React.useEffect(() => { if (isSearchActivating && scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } }, [isSearchActivating]); return chatThreadList; } const unboundStyles = { icon: { fontSize: 28, }, container: { flex: 1, }, searchContainer: { backgroundColor: 'listBackground', display: 'flex', justifyContent: 'center', flexDirection: 'row', }, flatList: { flex: 1, backgroundColor: 'listBackground', }, }; export default ChatThreadList; diff --git a/native/chat/compose-subchannel.react.js b/native/chat/compose-subchannel.react.js index 3b68b14d5..661ae9899 100644 --- a/native/chat/compose-subchannel.react.js +++ b/native/chat/compose-subchannel.react.js @@ -1,384 +1,387 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _sortBy from 'lodash/fp/sortBy.js'; import * as React from 'react'; import { View, Text } from 'react-native'; import { newThreadActionTypes, useNewThread, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors.js'; import { getPotentialMemberItems } from 'lib/shared/search-utils.js'; import { threadInFilterList, userIsMember } from 'lib/shared/thread-utils.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ThreadType, threadTypes } from 'lib/types/thread-types-enum.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { type AccountUserInfo } from 'lib/types/user-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import type { ChatNavigationProp } from './chat.react.js'; import { useNavigateToThread } from './message-list-types.js'; import ParentThreadHeader from './parent-thread-header.react.js'; import LinkButton from '../components/link-button.react.js'; -import { createTagInput } from '../components/tag-input.react.js'; +import { + createTagInput, + type BaseTagInput, +} from '../components/tag-input.react.js'; import ThreadList from '../components/thread-list.react.js'; import UserList from '../components/user-list.react.js'; import { useCalendarQuery } from '../navigation/nav-selectors.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; export type ComposeSubchannelParams = { +threadType: ThreadType, +parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, }; type Props = { +navigation: ChatNavigationProp<'ComposeSubchannel'>, +route: NavigationRoute<'ComposeSubchannel'>, }; function ComposeSubchannel(props: Props): React.Node { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const [createButtonEnabled, setCreateButtonEnabled] = React.useState(true); - const tagInputRef = React.useRef(); + const tagInputRef = React.useRef>(); const onUnknownErrorAlertAcknowledged = React.useCallback(() => { setUsernameInputText(''); tagInputRef.current?.focus(); }, []); const waitingOnThreadIDRef = React.useRef(); const { threadType, parentThreadInfo } = props.route.params; const userInfoInputIDs = userInfoInputArray.map(userInfo => userInfo.id); const callNewThread = useNewThread(); const calendarQuery = useCalendarQuery(); const newChatThreadAction = React.useCallback(async () => { try { const assumedThreadType = threadType ?? threadTypes.COMMUNITY_SECRET_SUBTHREAD; const query = calendarQuery(); invariant( assumedThreadType === 3 || assumedThreadType === 4 || assumedThreadType === 6 || assumedThreadType === 7, "Sidebars and communities can't be created from the thread composer", ); const result = await callNewThread({ type: assumedThreadType, parentThreadID: parentThreadInfo.id, initialMemberIDs: userInfoInputIDs, color: parentThreadInfo.color, calendarQuery: query, }); waitingOnThreadIDRef.current = result.newThreadID; return result; } catch (e) { setCreateButtonEnabled(true); Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } }, [ threadType, userInfoInputIDs, calendarQuery, parentThreadInfo, callNewThread, onUnknownErrorAlertAcknowledged, ]); const dispatchActionPromise = useDispatchActionPromise(); const dispatchNewChatThreadAction = React.useCallback(() => { setCreateButtonEnabled(false); dispatchActionPromise(newThreadActionTypes, newChatThreadAction()); }, [dispatchActionPromise, newChatThreadAction]); const userInfoInputArrayEmpty = userInfoInputArray.length === 0; const onPressCreateThread = React.useCallback(() => { if (!createButtonEnabled) { return; } if (userInfoInputArrayEmpty) { Alert.alert( 'Chatting to yourself?', 'Are you sure you want to create a channel containing only yourself?', [ { text: 'Cancel', style: 'cancel' }, { text: 'Confirm', onPress: dispatchNewChatThreadAction }, ], { cancelable: true }, ); } else { dispatchNewChatThreadAction(); } }, [ createButtonEnabled, userInfoInputArrayEmpty, dispatchNewChatThreadAction, ]); const { navigation } = props; const { setOptions } = navigation; React.useEffect(() => { setOptions({ // eslint-disable-next-line react/display-name headerRight: () => ( ), }); }, [setOptions, onPressCreateThread, createButtonEnabled]); const { setParams } = navigation; const parentThreadInfoID = parentThreadInfo.id; const reduxParentThreadInfo = useSelector( state => threadInfoSelector(state)[parentThreadInfoID], ); React.useEffect(() => { if (reduxParentThreadInfo) { setParams({ parentThreadInfo: reduxParentThreadInfo }); } }, [reduxParentThreadInfo, setParams]); const threadInfos = useSelector(threadInfoSelector); const newlyCreatedThreadInfo = waitingOnThreadIDRef.current ? threadInfos[waitingOnThreadIDRef.current] : null; const { pushNewThread } = navigation; React.useEffect(() => { if (!newlyCreatedThreadInfo) { return; } const waitingOnThreadID = waitingOnThreadIDRef.current; if (waitingOnThreadID === null || waitingOnThreadID === undefined) { return; } waitingOnThreadIDRef.current = undefined; pushNewThread(newlyCreatedThreadInfo); }, [newlyCreatedThreadInfo, pushNewThread]); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const { community } = parentThreadInfo; const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const userSearchResults = React.useMemo( () => getPotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, searchIndex: userSearchIndex, excludeUserIDs: userInfoInputIDs, inputParentThreadInfo: parentThreadInfo, inputCommunityThreadInfo: communityThreadInfo, threadType, }), [ usernameInputText, otherUserInfos, userSearchIndex, userInfoInputIDs, parentThreadInfo, communityThreadInfo, threadType, ], ); const existingThreads: $ReadOnlyArray = React.useMemo(() => { if (userInfoInputIDs.length === 0) { return []; } return _flow( _filter( (threadInfo: ThreadInfo) => threadInFilterList(threadInfo) && threadInfo.parentThreadID === parentThreadInfo.id && userInfoInputIDs.every(userID => userIsMember(threadInfo, userID)), ), _sortBy( ([ 'members.length', (threadInfo: ThreadInfo) => (threadInfo.name ? 1 : 0), ]: $ReadOnlyArray mixed)>), ), )(threadInfos); }, [userInfoInputIDs, threadInfos, parentThreadInfo]); const navigateToThread = useNavigateToThread(); const onSelectExistingThread = React.useCallback( (threadID: string) => { const threadInfo = threadInfos[threadID]; navigateToThread({ threadInfo }); }, [threadInfos, navigateToThread], ); const onUserSelect = React.useCallback( ({ id }: AccountUserInfo) => { if (userInfoInputIDs.some(existingUserID => id === existingUserID)) { return; } setUserInfoInputArray(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[id], ]); setUsernameInputText(''); }, [userInfoInputIDs, otherUserInfos], ); const styles = useStyles(unboundStyles); let existingThreadsSection = null; if (existingThreads.length > 0) { existingThreadsSection = ( Existing channels ); } const inputProps = React.useMemo( () => ({ ...tagInputProps, onSubmitEditing: onPressCreateThread, }), [onPressCreateThread], ); const userSearchResultWithENSNames = useENSNames(userSearchResults); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); return ( To: {existingThreadsSection} ); } const unboundStyles = { container: { flex: 1, }, existingThreadList: { backgroundColor: 'modalBackground', flex: 1, paddingRight: 12, }, existingThreads: { flex: 1, }, existingThreadsLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, textAlign: 'center', }, existingThreadsRow: { backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', borderTopWidth: 1, paddingVertical: 6, }, listItem: { color: 'modalForegroundLabel', }, tagInputContainer: { flex: 1, marginLeft: 8, paddingRight: 12, }, tagInputLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, }, userList: { backgroundColor: 'modalBackground', flex: 1, paddingLeft: 35, paddingRight: 12, }, userSelectionRow: { alignItems: 'center', backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, }; const MemoizedComposeSubchannel: React.ComponentType = React.memo(ComposeSubchannel); export default MemoizedComposeSubchannel; diff --git a/native/chat/message-editing-context-provider.react.js b/native/chat/message-editing-context-provider.react.js index e2acf1e55..9f866d795 100644 --- a/native/chat/message-editing-context-provider.react.js +++ b/native/chat/message-editing-context-provider.react.js @@ -1,79 +1,79 @@ // @flow import * as React from 'react'; import type { MessageInfo } from 'lib/types/message-types.js'; import { type EditState, MessageEditingContext, } from './message-editing-context.react.js'; const defaultEditState = { editedMessage: null, isEditedMessageChanged: false, }; type Props = { +children: React.Node, }; function MessageEditingContextProvider(props: Props): React.Node { const [editState, setEditState] = React.useState(defaultEditState); - const pendingCallbacksRef = React.useRef([]); + const pendingCallbacksRef = React.useRef void>>([]); const setEditedMessage = React.useCallback( (editedMessage: ?MessageInfo, callback?: () => void) => { if (callback) { pendingCallbacksRef.current.push(callback); } setEditState({ editedMessage, isEditedMessageChanged: false }); }, [], ); React.useEffect(() => { if (pendingCallbacksRef.current.length === 0) { return; } for (const callback of pendingCallbacksRef.current) { callback(); } pendingCallbacksRef.current = []; }, [editState]); const setEditedMessageChanged = React.useCallback( (isEditedMessageChanged: boolean) => { setEditState(prevEditState => { if (prevEditState.isEditedMessageChanged === isEditedMessageChanged) { return prevEditState; } return { ...prevEditState, isEditedMessageChanged, }; }); }, [], ); const contextValue = React.useMemo( () => ({ editState, setEditedMessage, setEditedMessageChanged, }), [editState, setEditedMessage, setEditedMessageChanged], ); return ( {props.children} ); } const MemoizedMessageEditingContextProvider: React.ComponentType = React.memo(MessageEditingContextProvider); export default MemoizedMessageEditingContextProvider; diff --git a/native/chat/message-reactions-modal.react.js b/native/chat/message-reactions-modal.react.js index d057831ac..9f079d310 100644 --- a/native/chat/message-reactions-modal.react.js +++ b/native/chat/message-reactions-modal.react.js @@ -1,191 +1,191 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import * as React from 'react'; import { View, Text, FlatList, TouchableHighlight, TouchableOpacity, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { useMessageReactionsList, type MessageReactionListInfo, } from 'lib/shared/reaction-utils.js'; import UserAvatar from '../avatars/user-avatar.react.js'; import Modal from '../components/modal.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import { type NavigationRoute } from '../navigation/route-names.js'; import { useColors, useStyles } from '../themes/colors.js'; import { useNavigateToUserProfileBottomSheet } from '../user-profile/user-profile-utils.js'; export type MessageReactionsModalParams = { +reactions: ReactionInfo, }; type Props = { +navigation: RootNavigationProp<'MessageReactionsModal'>, +route: NavigationRoute<'MessageReactionsModal'>, }; function MessageReactionsModal(props: Props): React.Node { const { navigation, route } = props; const { navigate, goBackOnce } = navigation; const { reactions } = route.params; const styles = useStyles(unboundStyles); const colors = useColors(); const modalSafeAreaEdges = React.useMemo(() => ['top'], []); const modalContainerSafeAreaEdges = React.useMemo(() => ['bottom'], []); const reactionsListData = useMessageReactionsList(reactions); const navigateToUserProfileBottomSheet = useNavigateToUserProfileBottomSheet(); - const [selectedUserID, setSelectedUserID] = React.useState(); + const [selectedUserID, setSelectedUserID] = React.useState(); // This useEffect will call navigateToUserProfileBottomSheet whenever the // MessageReactionsModal is unmounting and there is a selectedUserID. // This will make sure that the user profile bottom sheet slides in only // after MessageReactionsModal has finished sliding out. React.useEffect(() => { return () => { if (!selectedUserID) { return; } navigateToUserProfileBottomSheet(selectedUserID); }; }, [navigate, navigateToUserProfileBottomSheet, selectedUserID]); const onPressUser = React.useCallback( (userID: string) => { setSelectedUserID(userID); goBackOnce(); }, [goBackOnce, setSelectedUserID], ); const renderItem = React.useCallback( ({ item }: { +item: MessageReactionListInfo, ... }) => ( onPressUser(item.id)} key={item.id} style={styles.reactionsListRowContainer} > {item.username} {item.reaction} ), [ onPressUser, styles.reactionsListReactionText, styles.reactionsListRowContainer, styles.reactionsListUserInfoContainer, styles.reactionsListUsernameText, ], ); const itemSeperator = React.useCallback(() => { return ; }, [styles.reactionsListItemSeperator]); return ( All reactions ); } const unboundStyles = { modalStyle: { // we need to set each margin property explicitly to override marginLeft: 0, marginRight: 0, marginBottom: 0, marginTop: 0, justifyContent: 'flex-end', flex: 0, borderWidth: 0, borderTopLeftRadius: 10, borderTopRightRadius: 10, }, modalContainerStyle: { justifyContent: 'flex-end', }, modalContentContainer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24, marginTop: 8, }, reactionsListContentContainer: { paddingBottom: 16, }, reactionsListTitleText: { color: 'modalForegroundLabel', fontSize: 18, }, reactionsListRowContainer: { flexDirection: 'row', justifyContent: 'space-between', }, reactionsListUserInfoContainer: { flex: 1, flexDirection: 'row', alignItems: 'center', }, reactionsListUsernameText: { color: 'modalForegroundLabel', fontSize: 18, marginLeft: 8, }, reactionsListReactionText: { fontSize: 18, }, reactionsListItemSeperator: { height: 16, }, closeButton: { borderRadius: 4, width: 18, height: 18, alignItems: 'center', }, closeIcon: { color: 'modalBackgroundSecondaryLabel', }, }; export default MessageReactionsModal; diff --git a/native/chat/message-results-screen.react.js b/native/chat/message-results-screen.react.js index 3ac33e221..65fedf81b 100644 --- a/native/chat/message-results-screen.react.js +++ b/native/chat/message-results-screen.react.js @@ -1,183 +1,190 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { useFetchPinnedMessages } from 'lib/actions/message-actions.js'; import { messageListData } from 'lib/selectors/chat-selectors.js'; import { createMessageInfo, isInvalidPinSourceForThread, } from 'lib/shared/message-utils.js'; +import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useHeightMeasurer } from './chat-context.js'; import type { ChatNavigationProp } from './chat.react'; import type { NativeChatMessageItem } from './message-data.react.js'; import MessageResult from './message-result.react.js'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; +import type { VerticalBounds } from '../types/layout-types.js'; export type MessageResultsScreenParams = { +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, }; type MessageResultsScreenProps = { +navigation: ChatNavigationProp<'MessageResultsScreen'>, +route: NavigationRoute<'MessageResultsScreen'>, }; function MessageResultsScreen(props: MessageResultsScreenProps): React.Node { const { navigation, route } = props; const { threadInfo } = route.params; const styles = useStyles(unboundStyles); const { id: threadID } = threadInfo; - const [rawMessageResults, setRawMessageResults] = React.useState([]); + const [rawMessageResults, setRawMessageResults] = React.useState< + $ReadOnlyArray, + >([]); const measureMessages = useHeightMeasurer(); - const [measuredMessages, setMeasuredMessages] = React.useState([]); + const [measuredMessages, setMeasuredMessages] = React.useState< + $ReadOnlyArray, + >([]); - const [messageVerticalBounds, setMessageVerticalBounds] = React.useState(); - const scrollViewContainerRef = React.useRef(); + const [messageVerticalBounds, setMessageVerticalBounds] = + React.useState(); + const scrollViewContainerRef = React.useRef>(); const callFetchPinnedMessages = useFetchPinnedMessages(); const userInfos = useSelector(state => state.userStore.userInfos); React.useEffect(() => { (async () => { const result = await callFetchPinnedMessages({ threadID }); setRawMessageResults(result.pinnedMessages); })(); }, [callFetchPinnedMessages, threadID]); const translatedMessageResults = React.useMemo(() => { const threadInfos = { [threadID]: threadInfo }; return rawMessageResults .map(messageInfo => createMessageInfo(messageInfo, null, userInfos, threadInfos), ) .filter(Boolean); }, [rawMessageResults, userInfos, threadID, threadInfo]); const chatMessageInfos = useSelector( messageListData(threadInfo.id, translatedMessageResults), ); const sortedUniqueChatMessageInfoItems: $ReadOnlyArray = React.useMemo(() => { if (!chatMessageInfos) { return []; } const chatMessageInfoItems = chatMessageInfos.filter( item => item.itemType === 'message' && item.isPinned && !isInvalidPinSourceForThread(item.messageInfo, threadInfo), ); // By the nature of using messageListData and passing in // the desired translatedMessageResults as additional // messages, we will have duplicate ChatMessageInfoItems. const uniqueChatMessageInfoItemsMap = new Map(); chatMessageInfoItems.forEach( item => item.messageInfo && item.messageInfo.id && uniqueChatMessageInfoItemsMap.set(item.messageInfo.id, item), ); // Push the items in the order they appear in the rawMessageResults // since the messages fetched from the server are already sorted // in the order of pin_time (newest first). const sortedChatMessageInfoItems = []; for (let i = 0; i < rawMessageResults.length; i++) { sortedChatMessageInfoItems.push( uniqueChatMessageInfoItemsMap.get(rawMessageResults[i].id), ); } return sortedChatMessageInfoItems.filter(Boolean); }, [chatMessageInfos, rawMessageResults, threadInfo]); const measureCallback = React.useCallback( (listDataWithHeights: $ReadOnlyArray) => { setMeasuredMessages(listDataWithHeights); }, [], ); React.useEffect(() => { measureMessages( sortedUniqueChatMessageInfoItems, threadInfo, measureCallback, ); }, [ measureCallback, measureMessages, sortedUniqueChatMessageInfoItems, threadInfo, ]); const onLayout = React.useCallback(() => { scrollViewContainerRef.current?.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } setMessageVerticalBounds({ height, y: pageY }); }, ); }, []); const messageResultsToDisplay = React.useMemo( () => measuredMessages.map(item => { invariant(item.itemType !== 'loader', 'should not be loader'); return ( ); }), [measuredMessages, threadInfo, navigation, route, messageVerticalBounds], ); return ( {messageResultsToDisplay} ); } const unboundStyles = { scrollViewContainer: { flex: 1, }, }; export default MessageResultsScreen; diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js index c00fab564..169e4d8ee 100644 --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -1,297 +1,300 @@ // @flow import * as React from 'react'; import { View, Text, ActivityIndicator } from 'react-native'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors.js'; import { getPotentialMemberItems } from 'lib/shared/search-utils.js'; import { threadActualMembers } from 'lib/shared/thread-utils.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { type AccountUserInfo } from 'lib/types/user-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import Button from '../../components/button.react.js'; import Modal from '../../components/modal.react.js'; -import { createTagInput } from '../../components/tag-input.react.js'; +import { + createTagInput, + type BaseTagInput, +} from '../../components/tag-input.react.js'; import UserList from '../../components/user-list.react.js'; import type { RootNavigationProp } from '../../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles } from '../../themes/colors.js'; import Alert from '../../utils/alert.js'; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'Select users to add', autoFocus: true, returnKeyType: 'go', }; const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; export type AddUsersModalParams = { +presentedFrom: string, +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, }; type Props = { +navigation: RootNavigationProp<'AddUsersModal'>, +route: NavigationRoute<'AddUsersModal'>, }; function AddUsersModal(props: Props): React.Node { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); - const tagInputRef = React.useRef(); + const tagInputRef = React.useRef>(); const onUnknownErrorAlertAcknowledged = React.useCallback(() => { setUsernameInputText(''); setUserInfoInputArray([]); tagInputRef.current?.focus(); }, []); const { navigation } = props; const { goBackOnce } = navigation; const close = React.useCallback(() => { goBackOnce(); }, [goBackOnce]); const callChangeThreadSettings = useChangeThreadSettings(); const userInfoInputIDs = userInfoInputArray.map(userInfo => userInfo.id); const { route } = props; const { threadInfo } = route.params; const threadID = threadInfo.id; const addUsersToThread = React.useCallback(async () => { try { const result = await callChangeThreadSettings({ threadID: threadID, changes: { newMemberIDs: userInfoInputIDs }, }); close(); return result; } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } }, [ callChangeThreadSettings, threadID, userInfoInputIDs, close, onUnknownErrorAlertAcknowledged, ]); const inputLength = userInfoInputArray.length; const dispatchActionPromise = useDispatchActionPromise(); const userInfoInputArrayEmpty = inputLength === 0; const onPressAdd = React.useCallback(() => { if (userInfoInputArrayEmpty) { return; } dispatchActionPromise(changeThreadSettingsActionTypes, addUsersToThread()); }, [userInfoInputArrayEmpty, dispatchActionPromise, addUsersToThread]); const changeThreadSettingsLoadingStatus = useSelector( createLoadingStatusSelector(changeThreadSettingsActionTypes), ); const isLoading = changeThreadSettingsLoadingStatus === 'loading'; const styles = useStyles(unboundStyles); let addButton = null; if (inputLength > 0) { let activityIndicator = null; if (isLoading) { activityIndicator = ( ); } const addButtonText = `Add (${inputLength})`; addButton = ( ); } let cancelButton; if (!isLoading) { cancelButton = ( ); } else { cancelButton = ; } const threadMemberIDs = React.useMemo( () => threadActualMembers(threadInfo.members), [threadInfo.members], ); const excludeUserIDs = React.useMemo( () => userInfoInputIDs.concat(threadMemberIDs), [userInfoInputIDs, threadMemberIDs], ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const { parentThreadID, community } = props.route.params.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const userSearchResults = React.useMemo( () => getPotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, searchIndex: userSearchIndex, excludeUserIDs, inputParentThreadInfo: parentThreadInfo, inputCommunityThreadInfo: communityThreadInfo, threadType: threadInfo.type, }), [ usernameInputText, otherUserInfos, userSearchIndex, excludeUserIDs, parentThreadInfo, communityThreadInfo, threadInfo.type, ], ); const onChangeTagInput = React.useCallback( (newUserInfoInputArray: $ReadOnlyArray) => { if (!isLoading) { setUserInfoInputArray(newUserInfoInputArray); } }, [isLoading], ); const onChangeTagInputText = React.useCallback( (text: string) => { if (!isLoading) { setUsernameInputText(text); } }, [isLoading], ); const onUserSelect = React.useCallback( ({ id }: AccountUserInfo) => { if (isLoading) { return; } if (userInfoInputIDs.some(existingUserID => id === existingUserID)) { return; } setUserInfoInputArray(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[id], ]); setUsernameInputText(''); }, [isLoading, userInfoInputIDs, otherUserInfos], ); const inputProps = React.useMemo( () => ({ ...tagInputProps, onSubmitEditing: onPressAdd, }), [onPressAdd], ); const userSearchResultWithENSNames = useENSNames(userSearchResults); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); return ( {cancelButton} {addButton} ); } const unboundStyles = { activityIndicator: { paddingRight: 6, }, addButton: { backgroundColor: 'vibrantGreenButton', borderRadius: 3, flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 4, }, addText: { color: 'white', fontSize: 18, }, buttons: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12, }, cancelButton: { backgroundColor: 'modalButton', borderRadius: 3, paddingHorizontal: 10, paddingVertical: 4, }, cancelText: { color: 'modalButtonLabel', fontSize: 18, }, }; const MemoizedAddUsersModal: React.ComponentType = React.memo(AddUsersModal); export default MemoizedAddUsersModal; diff --git a/native/chat/settings/thread-settings-media-gallery.react.js b/native/chat/settings/thread-settings-media-gallery.react.js index 9b081ac8d..9b3aec71e 100644 --- a/native/chat/settings/thread-settings-media-gallery.react.js +++ b/native/chat/settings/thread-settings-media-gallery.react.js @@ -1,225 +1,225 @@ // @flow import { useNavigation, useRoute } from '@react-navigation/native'; import * as React from 'react'; import { View, useWindowDimensions } from 'react-native'; import type { ViewStyleProp } from 'react-native/Libraries/StyleSheet/StyleSheet'; import { FlatList } from 'react-native-gesture-handler'; import { useFetchThreadMedia } from 'lib/actions/thread-actions.js'; import type { MediaInfo, Media } from 'lib/types/media-types'; import GestureTouchableOpacity from '../../components/gesture-touchable-opacity.react.js'; import Multimedia from '../../media/multimedia.react.js'; import { ImageModalRouteName, VideoPlaybackModalRouteName, } from '../../navigation/route-names.js'; import { useStyles } from '../../themes/colors.js'; import type { LayoutCoordinates, VerticalBounds, } from '../../types/layout-types.js'; const galleryItemGap = 8; const numColumns = 3; type ThreadSettingsMediaGalleryProps = { +threadID: string, +limit: number, +verticalBounds: ?VerticalBounds, +offset?: number, +activeTab?: string, }; function ThreadSettingsMediaGallery( props: ThreadSettingsMediaGalleryProps, ): React.Node { const styles = useStyles(unboundStyles); const { width } = useWindowDimensions(); // Explanation of galleryItemWidth: // The FlatList has a horizontal padding of 16px on each side, // and so the width of the actual FlatList is `width - 32px`. // With three columns, there will be two gaps in between the items, // so the width of each item (with the gaps) will be // (width - 32px - (numColumns-1) * galleryItemGap) / numColumns. // E.g. 16px, media, galleryItemGap, media, galleryItemGap, media, 16px const galleryItemWidth = (width - 32 - (numColumns - 1) * galleryItemGap) / numColumns; const { threadID, limit, verticalBounds, offset, activeTab } = props; - const [mediaInfos, setMediaInfos] = React.useState([]); + const [mediaInfos, setMediaInfos] = React.useState<$ReadOnlyArray>([]); const callFetchThreadMedia = useFetchThreadMedia(); React.useEffect(() => { const fetchData = async () => { const result = await callFetchThreadMedia({ threadID, limit, offset: 0, }); setMediaInfos(result.media); }; fetchData(); }, [callFetchThreadMedia, threadID, limit]); const memoizedStyles = React.useMemo(() => { return { mediaContainer: { marginTop: galleryItemGap, width: galleryItemWidth, ...styles.mediaContainer, }, mediaContainerWithMargin: { marginTop: galleryItemGap, marginLeft: galleryItemGap, width: galleryItemWidth, ...styles.mediaContainer, }, media: { width: galleryItemWidth, ...styles.media, }, }; }, [galleryItemWidth, styles.media, styles.mediaContainer]); const filteredMediaInfos = React.useMemo(() => { if (activeTab === 'ALL') { return mediaInfos; } else if (activeTab === 'IMAGES') { return mediaInfos.filter( mediaInfo => mediaInfo.type === 'photo' || mediaInfo.type === 'encrypted_photo', ); } else if (activeTab === 'VIDEOS') { return mediaInfos.filter( mediaInfo => mediaInfo.type === 'video' || mediaInfo.type === 'encrypted_video', ); } return mediaInfos; }, [activeTab, mediaInfos]); const renderItem = React.useCallback( ({ item, index }: { +item: Media, +index: number, ... }) => ( ), [threadID, verticalBounds, memoizedStyles], ); const onEndReached = React.useCallback(async () => { // As the FlatList fetches more media, we set the offset to be the length // of mediaInfos. This will ensure that the next set of media is retrieved // from the starting point. const result = await callFetchThreadMedia({ threadID, limit, offset: mediaInfos.length, }); setMediaInfos([...mediaInfos, ...result.media]); }, [callFetchThreadMedia, mediaInfos, threadID, limit]); return ( ); } type MediaGalleryItemProps = { +item: Media, +index: number, +memoizedStyles: { +mediaContainer: ViewStyleProp, +mediaContainerWithMargin: ViewStyleProp, +media: ViewStyleProp, }, +threadID: string, +verticalBounds: ?VerticalBounds, }; function MediaGalleryItem(props: MediaGalleryItemProps): React.Node { const navigation = useNavigation(); const route = useRoute(); - const ref = React.useRef(null); + const ref = React.useRef>(null); const onLayout = React.useCallback(() => {}, []); const { threadID, verticalBounds, memoizedStyles, item, index } = props; const mediaInfo: MediaInfo = React.useMemo( () => ({ ...(item: Media), index, }), [item, index], ); const navigateToMedia = React.useCallback(() => { ref.current?.measure((x, y, width, height, pageX, pageY) => { const initialCoordinates: LayoutCoordinates = { x: pageX, y: pageY, width, height, }; navigation.navigate<'VideoPlaybackModal' | 'ImageModal'>({ name: mediaInfo.type === 'video' || mediaInfo.type === 'encrypted_video' ? VideoPlaybackModalRouteName : ImageModalRouteName, key: `multimedia|${threadID}|${mediaInfo.id}`, params: { presentedFrom: route.key, mediaInfo, item, initialCoordinates, verticalBounds, }, }); }); }, [navigation, route, threadID, mediaInfo, item, verticalBounds]); const containerStyle = index % numColumns === 0 ? memoizedStyles.mediaContainer : memoizedStyles.mediaContainerWithMargin; return ( ); } const unboundStyles = { flatListContainer: { paddingHorizontal: 16, }, mediaContainer: { height: 180, justifyContent: 'center', alignItems: 'center', }, media: { height: 180, }, }; export default ThreadSettingsMediaGallery; diff --git a/native/chat/swipeable-thread.react.js b/native/chat/swipeable-thread.react.js index 94df83a3c..914a29f29 100644 --- a/native/chat/swipeable-thread.react.js +++ b/native/chat/swipeable-thread.react.js @@ -1,98 +1,100 @@ // @flow import MaterialIcon from '@expo/vector-icons/MaterialCommunityIcons.js'; import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; +// eslint-disable-next-line import/extensions +import SwipeableComponent from 'react-native-gesture-handler/Swipeable'; import useToggleUnreadStatus from 'lib/hooks/toggle-unread-status.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import Swipeable from '../components/swipeable.js'; import { useColors } from '../themes/colors.js'; type Props = { +threadInfo: ThreadInfo, +mostRecentNonLocalMessage: ?string, +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId?: string, +iconSize: number, +children: React.Node, }; function SwipeableThread(props: Props): React.Node { - const swipeable = React.useRef(); + const swipeable = React.useRef(); const navigation = useNavigation(); React.useEffect(() => { return navigation.addListener('blur', () => { if (swipeable.current) { swipeable.current.close(); } }); }, [navigation, swipeable]); const { threadInfo, currentlyOpenedSwipeableId } = props; React.useEffect(() => { if (swipeable.current && threadInfo.id !== currentlyOpenedSwipeableId) { swipeable.current.close(); } }, [currentlyOpenedSwipeableId, swipeable, threadInfo.id]); const { onSwipeableWillOpen } = props; const onSwipeableRightWillOpen = React.useCallback(() => { onSwipeableWillOpen(threadInfo); }, [onSwipeableWillOpen, threadInfo]); const colors = useColors(); const { mostRecentNonLocalMessage, iconSize } = props; const swipeableClose = React.useCallback(() => { if (swipeable.current) { swipeable.current.close(); } }, []); const toggleUnreadStatus = useToggleUnreadStatus( threadInfo, mostRecentNonLocalMessage, swipeableClose, ); const swipeableActions = React.useMemo(() => { const isUnread = threadInfo.currentUser.unread; return [ { key: 'action1', onPress: toggleUnreadStatus, color: isUnread ? colors.vibrantRedButton : colors.vibrantGreenButton, content: ( ), }, ]; }, [ threadInfo.currentUser.unread, toggleUnreadStatus, colors.vibrantRedButton, colors.vibrantGreenButton, iconSize, ]); const swipeableThread = React.useMemo( () => ( {props.children} ), [onSwipeableRightWillOpen, props.children, swipeableActions], ); return swipeableThread; } export default SwipeableThread; diff --git a/native/chat/thread-list-modal.react.js b/native/chat/thread-list-modal.react.js index 14c4d3ae6..e8083eb08 100644 --- a/native/chat/thread-list-modal.react.js +++ b/native/chat/thread-list-modal.react.js @@ -1,197 +1,198 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { Text, TextInput, FlatList, View, TouchableOpacity, } from 'react-native'; import type { ThreadSearchState } from 'lib/hooks/search-threads.js'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import type { SetState } from 'lib/types/hook-types.js'; import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types.js'; import { useNavigateToThread } from './message-list-types.js'; import Modal from '../components/modal.react.js'; import Search from '../components/search.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import ThreadPill from '../components/thread-pill.react.js'; import { useIndicatorStyle, useStyles } from '../themes/colors.js'; import { waitForModalInputFocus } from '../utils/timers.js'; function keyExtractor(sidebarInfo: SidebarInfo | ChatThreadItem) { return sidebarInfo.threadInfo.id; } function getItemLayout( data: ?$ReadOnlyArray, index: number, ) { return { length: 24, offset: 24 * index, index }; } type Props = { +threadInfo: ThreadInfo, +createRenderItem: (onPressItem: (threadInfo: ThreadInfo) => void) => (row: { +item: U, +index: number, ... }) => React.Node, +listData: $ReadOnlyArray, +searchState: ThreadSearchState, +setSearchState: SetState, +onChangeSearchInputText: (text: string) => mixed, +searchPlaceholder?: string, +modalTitle: string, }; function ThreadListModal( props: Props, ): React.Node { const { threadInfo: parentThreadInfo, searchState, setSearchState, onChangeSearchInputText, listData, createRenderItem, searchPlaceholder, modalTitle, } = props; - const searchTextInputRef = React.useRef(); + const searchTextInputRef = + React.useRef>(); const setSearchTextInputRef = React.useCallback( async (textInput: ?React.ElementRef) => { searchTextInputRef.current = textInput; if (!textInput) { return; } await waitForModalInputFocus(); if (searchTextInputRef.current) { searchTextInputRef.current.focus(); } }, [], ); const navigateToThread = useNavigateToThread(); const onPressItem = React.useCallback( (threadInfo: ThreadInfo) => { setSearchState({ text: '', results: new Set(), }); if (searchTextInputRef.current) { searchTextInputRef.current.blur(); } navigateToThread({ threadInfo }); }, [navigateToThread, setSearchState], ); const renderItem = React.useMemo( () => createRenderItem(onPressItem), [createRenderItem, onPressItem], ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const navigation = useNavigation(); return ( {modalTitle} ); } const unboundStyles = { parentNameWrapper: { alignItems: 'flex-start', }, body: { paddingHorizontal: 16, flex: 1, }, headerTopRow: { flexDirection: 'row', justifyContent: 'space-between', height: 32, alignItems: 'center', }, header: { borderBottomColor: 'subthreadsModalSearch', borderBottomWidth: 1, height: 94, padding: 16, justifyContent: 'space-between', }, modal: { borderRadius: 8, paddingHorizontal: 0, backgroundColor: 'subthreadsModalBackground', paddingTop: 0, justifyContent: 'flex-start', }, search: { height: 40, marginVertical: 16, backgroundColor: 'subthreadsModalSearch', }, title: { color: 'listForegroundLabel', fontSize: 20, fontWeight: '500', lineHeight: 26, alignSelf: 'center', marginLeft: 2, }, closeIcon: { color: 'subthreadsModalClose', }, closeButton: { marginRight: 2, height: 40, alignItems: 'center', justifyContent: 'center', }, }; export default ThreadListModal; diff --git a/native/community-creation/community-creation-members.react.js b/native/community-creation/community-creation-members.react.js index 6bff818c8..041877a45 100644 --- a/native/community-creation/community-creation-members.react.js +++ b/native/community-creation/community-creation-members.react.js @@ -1,199 +1,202 @@ // @flow import * as React from 'react'; import { ActivityIndicator } from 'react-native'; import { useChangeThreadSettings, changeThreadSettingsActionTypes, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors.js'; import { getPotentialMemberItems } from 'lib/shared/search-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { AccountUserInfo } from 'lib/types/user-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import CommunityCreationContentContainer from './community-creation-content-container.react.js'; import CommunityCreationKeyserverLabel from './community-creation-keyserver-label.react.js'; import type { CommunityCreationNavigationProp } from './community-creation-navigator.react.js'; import RegistrationContainer from '../account/registration/registration-container.react.js'; import { useNavigateToThread } from '../chat/message-list-types.js'; import LinkButton from '../components/link-button.react.js'; -import { createTagInput } from '../components/tag-input.react.js'; +import { + createTagInput, + type BaseTagInput, +} from '../components/tag-input.react.js'; import UserList from '../components/user-list.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; export type CommunityCreationMembersScreenParams = { +announcement: boolean, +threadID: string, }; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; type Props = { +navigation: CommunityCreationNavigationProp<'CommunityCreationMembers'>, +route: NavigationRoute<'CommunityCreationMembers'>, }; const changeThreadSettingsLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, ); function CommunityCreationMembers(props: Props): React.Node { const { announcement, threadID } = props.route.params; const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useChangeThreadSettings(); const changeThreadSettingsLoadingStatus: LoadingStatus = useSelector( changeThreadSettingsLoadingStatusSelector, ); const { navigation } = props; const { setOptions } = navigation; const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const [usernameInputText, setUsernameInputText] = React.useState(''); const [selectedUsers, setSelectedUsers] = React.useState< $ReadOnlyArray, >([]); const selectedUserIDs = React.useMemo( () => selectedUsers.map(userInfo => userInfo.id), [selectedUsers], ); const navigateToThread = useNavigateToThread(); const threadInfos = useSelector(threadInfoSelector); const communityThreadInfo = threadInfos[threadID]; const addSelectedUsersToCommunity = React.useCallback(() => { dispatchActionPromise( changeThreadSettingsActionTypes, (async () => { const result = await callChangeThreadSettings({ threadID, changes: { newMemberIDs: selectedUserIDs }, }); navigateToThread({ threadInfo: communityThreadInfo }); return result; })(), ); }, [ callChangeThreadSettings, communityThreadInfo, dispatchActionPromise, navigateToThread, selectedUserIDs, threadID, ]); const exitCommunityCreationFlow = React.useCallback(() => { navigateToThread({ threadInfo: communityThreadInfo }); }, [communityThreadInfo, navigateToThread]); const activityIndicatorStyle = React.useMemo( () => ({ paddingRight: 20 }), [], ); React.useEffect(() => { setOptions({ // eslint-disable-next-line react/display-name headerRight: () => { if (changeThreadSettingsLoadingStatus === 'loading') { return ( ); } return ( ); }, }); }, [ activityIndicatorStyle, addSelectedUsersToCommunity, changeThreadSettingsLoadingStatus, exitCommunityCreationFlow, selectedUserIDs.length, setOptions, ]); const userSearchResults = React.useMemo( () => getPotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, searchIndex: userSearchIndex, excludeUserIDs: selectedUserIDs, threadType: announcement ? threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT : threadTypes.COMMUNITY_ROOT, }), [ announcement, otherUserInfos, selectedUserIDs, userSearchIndex, usernameInputText, ], ); const onSelectUser = React.useCallback( ({ id }: AccountUserInfo) => { if (selectedUserIDs.some(existingUserID => id === existingUserID)) { return; } setSelectedUsers(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[id], ]); setUsernameInputText(''); }, [otherUserInfos, selectedUserIDs], ); - const tagInputRef = React.useRef(); + const tagInputRef = React.useRef>(); return ( ); } export default CommunityCreationMembers; diff --git a/native/components/gesture-touchable-opacity.react.js b/native/components/gesture-touchable-opacity.react.js index 2fa7c8b53..c7925f5c9 100644 --- a/native/components/gesture-touchable-opacity.react.js +++ b/native/components/gesture-touchable-opacity.react.js @@ -1,254 +1,254 @@ // @flow import * as React from 'react'; import { StyleSheet } from 'react-native'; import { LongPressGestureHandler, TapGestureHandler, State as GestureState, type LongPressGestureEvent, type TapGestureEvent, } from 'react-native-gesture-handler'; import Animated, { EasingNode } from 'react-native-reanimated'; import type { ReactRefSetter } from 'lib/types/react-types.js'; import type { AnimatedViewStyle, ViewStyle } from '../types/styles.js'; import { runTiming, useReanimatedValueForBoolean, } from '../utils/animation-utils.js'; /* eslint-disable import/no-named-as-default-member */ const { Clock, block, event, set, call, cond, not, and, or, eq, stopClock, clockRunning, useValue, } = Animated; /* eslint-enable import/no-named-as-default-member */ const pressAnimationSpec = { duration: 150, easing: EasingNode.inOut(EasingNode.quad), }; const resetAnimationSpec = { duration: 250, easing: EasingNode.inOut(EasingNode.quad), }; type Props = { +activeOpacity?: number, +onPress?: () => mixed, +onLongPress?: () => mixed, +children?: React.Node, +style?: ViewStyle, +animatedStyle?: AnimatedViewStyle, // If stickyActive is a boolean, we assume that we should stay active after a // successful onPress or onLongPress. We will wait for stickyActive to flip // from true to false before animating back to our deactivated mode. +stickyActive?: boolean, +overlay?: React.Node, +disabled?: boolean, }; function ForwardedGestureTouchableOpacity( props: Props, ref: ReactRefSetter, ) { const { onPress: innerOnPress, onLongPress: innerOnLongPress } = props; const onPress = React.useCallback(() => { innerOnPress && innerOnPress(); }, [innerOnPress]); const onLongPress = React.useCallback(() => { innerOnLongPress && innerOnLongPress(); }, [innerOnLongPress]); const activeOpacity = props.activeOpacity ?? 0.2; const { stickyActive, disabled } = props; const activeValue = useReanimatedValueForBoolean(!!stickyActive); const disabledValue = useReanimatedValueForBoolean(!!disabled); const stickyActiveEnabled = stickyActive !== null && stickyActive !== undefined; const longPressState = useValue(-1); const tapState = useValue(-1); const longPressEvent = React.useMemo( () => event([ { nativeEvent: { state: longPressState, }, }, ]), [longPressState], ); const tapEvent = React.useMemo( () => event([ { nativeEvent: { state: tapState, }, }, ]), [tapState], ); const gestureActive = React.useMemo( () => or( eq(longPressState, GestureState.ACTIVE), eq(tapState, GestureState.BEGAN), eq(tapState, GestureState.ACTIVE), activeValue, ), [longPressState, tapState, activeValue], ); const curOpacity = useValue(1); - const pressClockRef = React.useRef(); + const pressClockRef = React.useRef(); if (!pressClockRef.current) { pressClockRef.current = new Clock(); } const pressClock = pressClockRef.current; - const resetClockRef = React.useRef(); + const resetClockRef = React.useRef(); if (!resetClockRef.current) { resetClockRef.current = new Clock(); } const resetClock = resetClockRef.current; const animationCode = React.useMemo( () => [ cond(or(gestureActive, clockRunning(pressClock)), [ set( curOpacity, runTiming( pressClock, curOpacity, activeOpacity, true, pressAnimationSpec, ), ), stopClock(resetClock), ]), // We have to do two separate conds here even though the condition is the // same because if runTiming stops the pressClock, we need to immediately // start the resetClock or Reanimated won't keep running the code because // it will think there is nothing left to do cond( not(or(gestureActive, clockRunning(pressClock))), set( curOpacity, runTiming(resetClock, curOpacity, 1, true, resetAnimationSpec), ), ), ], [gestureActive, curOpacity, pressClock, resetClock, activeOpacity], ); const prevTapSuccess = useValue(0); const prevLongPressSuccess = useValue(0); const transformStyle = React.useMemo(() => { const tapSuccess = eq(tapState, GestureState.END); const longPressSuccess = eq(longPressState, GestureState.ACTIVE); const opacity = block([ ...animationCode, [ cond(and(tapSuccess, not(prevTapSuccess), not(disabledValue)), [ stickyActiveEnabled ? set(activeValue, 1) : undefined, call([], onPress), ]), set(prevTapSuccess, tapSuccess), ], [ cond( and(longPressSuccess, not(prevLongPressSuccess), not(disabledValue)), [ stickyActiveEnabled ? set(activeValue, 1) : undefined, call([], onLongPress), ], ), set(prevLongPressSuccess, longPressSuccess), ], curOpacity, ]); return { opacity }; }, [ animationCode, tapState, longPressState, prevTapSuccess, prevLongPressSuccess, curOpacity, onPress, onLongPress, activeValue, disabledValue, stickyActiveEnabled, ]); const fillStyle = React.useMemo(() => { const result = StyleSheet.flatten(props.style); if (!result) { return undefined; } const { flex } = result; if (flex === null || flex === undefined) { return undefined; } return { flex }; }, [props.style]); const tapHandler = ( {props.children} {props.overlay} ); if (!innerOnLongPress) { return tapHandler; } return ( {tapHandler} ); } const GestureTouchableOpacity: React.AbstractComponent< Props, TapGestureHandler, > = React.forwardRef( ForwardedGestureTouchableOpacity, ); GestureTouchableOpacity.displayName = 'GestureTouchableOpacity'; export default GestureTouchableOpacity; diff --git a/native/components/search.react.js b/native/components/search.react.js index b987d812c..db25eb173 100644 --- a/native/components/search.react.js +++ b/native/components/search.react.js @@ -1,149 +1,149 @@ // @flow import * as React from 'react'; import { View, TouchableOpacity, TextInput as BaseTextInput, Text, Platform, } from 'react-native'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import type { ReactRefSetter } from 'lib/types/react-types.js'; import SWMansionIcon from './swmansion-icon.react.js'; import TextInput from './text-input.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles, useColors } from '../themes/colors.js'; import type { ViewStyle } from '../types/styles.js'; type Props = { ...React.ElementConfig, +searchText: string, +onChangeText: (searchText: string) => mixed, +containerStyle?: ViewStyle, +active?: boolean, }; function ForwardedSearch( props: Props, ref: ReactRefSetter>, ) { const { onChangeText, searchText, containerStyle, active, ...rest } = props; const clearSearch = React.useCallback(() => { onChangeText(''); }, [onChangeText]); const loggedIn = useSelector(isLoggedIn); const styles = useStyles(unboundStyles); const colors = useColors(); - const prevLoggedInRef = React.useRef(); + const prevLoggedInRef = React.useRef(); React.useEffect(() => { const prevLoggedIn = prevLoggedInRef.current; prevLoggedInRef.current = loggedIn; if (!loggedIn && prevLoggedIn) { clearSearch(); } }, [loggedIn, clearSearch]); const { listSearchIcon: iconColor } = colors; let clearSearchInputIcon = null; if (searchText) { clearSearchInputIcon = ( ); } const inactive = active === false; const usingPlaceholder = !searchText && rest.placeholder; const inactiveTextStyle = React.useMemo( () => inactive && usingPlaceholder ? [styles.searchText, styles.inactiveSearchText, { color: iconColor }] : [styles.searchText, styles.inactiveSearchText], [ inactive, usingPlaceholder, styles.searchText, styles.inactiveSearchText, iconColor, ], ); let textNode; if (!inactive) { const textInputProps: React.ElementProps = { style: styles.searchText, value: searchText, onChangeText: onChangeText, placeholderTextColor: iconColor, returnKeyType: 'go', }; textNode = ; } else { const text = usingPlaceholder ? rest.placeholder : searchText; textNode = {text}; } return ( {textNode} {clearSearchInputIcon} ); } const Search: React.AbstractComponent< Props, React.ElementRef, > = React.forwardRef>( ForwardedSearch, ); Search.displayName = 'Search'; const unboundStyles = { search: { alignItems: 'center', backgroundColor: 'listSearchBackground', borderRadius: 8, flexDirection: 'row', paddingLeft: 14, paddingRight: 7, }, inactiveSearchText: { transform: Platform.select({ ios: [{ translateY: 1 / 3 }], default: undefined, }), }, searchText: { color: 'listForegroundLabel', flex: 1, fontSize: 16, marginLeft: 8, marginVertical: 6, padding: 0, borderBottomColor: 'transparent', }, clearSearchButton: { paddingVertical: 5, paddingLeft: 5, }, }; const MemoizedSearch: typeof Search = React.memo< Props, React.ElementRef, >(Search); export default MemoizedSearch; diff --git a/native/media/video-playback-modal.react.js b/native/media/video-playback-modal.react.js index 2ad80ff8a..d50bf3635 100644 --- a/native/media/video-playback-modal.react.js +++ b/native/media/video-playback-modal.react.js @@ -1,829 +1,834 @@ // @flow import Icon from '@expo/vector-icons/MaterialCommunityIcons.js'; import invariant from 'invariant'; import * as React from 'react'; import { useState } from 'react'; import { View, Text, TouchableOpacity } from 'react-native'; import filesystem from 'react-native-fs'; import { TapGestureHandler, type TapGestureEvent, } from 'react-native-gesture-handler'; import * as Progress from 'react-native-progress'; import Animated from 'react-native-reanimated'; import { SafeAreaView } from 'react-native-safe-area-context'; import Video from 'react-native-video'; import { MediaCacheContext } from 'lib/components/media-cache-provider.react.js'; import { useIsAppBackgroundedOrInactive } from 'lib/shared/lifecycle-utils.js'; import type { MediaInfo } from 'lib/types/media-types.js'; import { decryptMedia } from './encryption-utils.js'; import { formatDuration } from './video-utils.js'; import ConnectedStatusBar from '../connected-status-bar.react.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { derivedDimensionsInfoSelector } from '../selectors/dimensions-selectors.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMultimediaMessageInfoItem } from '../types/chat-types.js'; import type { VerticalBounds, LayoutCoordinates, } from '../types/layout-types.js'; import type { NativeMethods } from '../types/react-native.js'; import { gestureJustEnded, animateTowards } from '../utils/animation-utils.js'; type TouchableOpacityInstance = React.AbstractComponent< React.ElementConfig, NativeMethods, >; +type VideoRef = { + +seek: number => mixed, + ... +}; + /* eslint-disable import/no-named-as-default-member */ const { Extrapolate, and, or, block, cond, eq, ceil, call, set, add, sub, multiply, divide, not, max, min, lessThan, greaterThan, abs, interpolateNode, useValue, event, } = Animated; export type VideoPlaybackModalParams = { +presentedFrom: string, +mediaInfo: MediaInfo, +initialCoordinates: LayoutCoordinates, +verticalBounds: VerticalBounds, +item: ChatMultimediaMessageInfoItem, }; type ReactNativeVideoOnProgressData = { +currentTime: number, +playableDuration: number, +seekableDuration: number, }; type Props = { +navigation: AppNavigationProp<'VideoPlaybackModal'>, +route: NavigationRoute<'VideoPlaybackModal'>, }; function VideoPlaybackModal(props: Props): React.Node { const { mediaInfo } = props.route.params; const { uri: videoUri, holder: blobURI, encryptionKey } = mediaInfo; const [videoSource, setVideoSource] = React.useState( videoUri ? { uri: videoUri } : undefined, ); const mediaCache = React.useContext(MediaCacheContext); React.useEffect(() => { // skip for unencrypted videos if (!blobURI || !encryptionKey) { return undefined; } let isMounted = true; let uriToDispose; setVideoSource(undefined); const loadDecrypted = async () => { const cached = await mediaCache?.get(blobURI); if (cached && isMounted) { setVideoSource({ uri: cached }); return; } const { result } = await decryptMedia(blobURI, encryptionKey, { destination: 'file', }); if (result.success) { const { uri } = result; const cacheSetPromise = mediaCache?.set(blobURI, uri); if (isMounted) { uriToDispose = uri; setVideoSource({ uri }); } else { // dispose of the temporary file immediately when unmounted // but wait for the cache to be set await cacheSetPromise; filesystem.unlink(uri); } } }; loadDecrypted(); return () => { isMounted = false; if (uriToDispose) { // remove the temporary file created by decryptMedia filesystem.unlink(uriToDispose); } }; }, [blobURI, encryptionKey, mediaCache]); const closeButtonX = useValue(-1); const closeButtonY = useValue(-1); const closeButtonWidth = useValue(-1); const closeButtonHeight = useValue(-1); const closeButtonRef = React.useRef>(); const closeButton = closeButtonRef.current; const onCloseButtonLayoutCalledRef = React.useRef(false); const onCloseButtonLayout = React.useCallback(() => { onCloseButtonLayoutCalledRef.current = true; }, []); const onCloseButtonLayoutCalled = onCloseButtonLayoutCalledRef.current; React.useEffect(() => { if (!closeButton || !onCloseButtonLayoutCalled) { return; } closeButton.measure((x, y, width, height, pageX, pageY) => { closeButtonX.setValue(pageX); closeButtonY.setValue(pageY); closeButtonWidth.setValue(width); closeButtonHeight.setValue(height); }); }, [ closeButton, onCloseButtonLayoutCalled, closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, ]); const footerX = useValue(-1); const footerY = useValue(-1); const footerWidth = useValue(-1); const footerHeight = useValue(-1); - const footerRef = React.useRef(); + const footerRef = React.useRef>(); const footer = footerRef.current; const onFooterLayoutCalledRef = React.useRef(false); const onFooterLayout = React.useCallback(() => { onFooterLayoutCalledRef.current = true; }, []); const onFooterLayoutCalled = onFooterLayoutCalledRef.current; React.useEffect(() => { if (!footer || !onFooterLayoutCalled) { return; } footer.measure((x, y, width, height, pageX, pageY) => { footerX.setValue(pageX); footerY.setValue(pageY); footerWidth.setValue(width); footerHeight.setValue(height); }); }, [ footer, onFooterLayoutCalled, footerX, footerY, footerWidth, footerHeight, ]); const controlsShowing = useValue(1); const outsideButtons = React.useCallback( (x: Animated.Value, y: Animated.Value) => and( or( eq(controlsShowing, 0), lessThan(x, closeButtonX), greaterThan(x, add(closeButtonX, closeButtonWidth)), lessThan(y, closeButtonY), greaterThan(y, add(closeButtonY, closeButtonHeight)), ), or( eq(controlsShowing, 0), lessThan(x, footerX), greaterThan(x, add(footerX, footerWidth)), lessThan(y, footerY), greaterThan(y, add(footerY, footerHeight)), ), ), [ controlsShowing, closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, footerX, footerY, footerWidth, footerHeight, ], ); /* ===== START FADE CONTROL ANIMATION ===== */ const singleTapState = useValue(-1); const singleTapX = useValue(0); const singleTapY = useValue(0); const singleTapEvent = React.useMemo( () => event([ { nativeEvent: { state: singleTapState, x: singleTapX, y: singleTapY, }, }, ]), [singleTapState, singleTapX, singleTapY], ); const lastTapX = useValue(-1); const lastTapY = useValue(-1); const activeControlsOpacity = React.useMemo( () => animateTowards( block([ cond( and( gestureJustEnded(singleTapState), outsideButtons(lastTapX, lastTapY), ), set(controlsShowing, not(controlsShowing)), ), set(lastTapX, singleTapX), set(lastTapY, singleTapY), controlsShowing, ]), 150, ), [ singleTapState, controlsShowing, outsideButtons, lastTapX, lastTapY, singleTapX, singleTapY, ], ); const [controlsEnabled, setControlsEnabled] = React.useState(true); const enableControls = React.useCallback(() => setControlsEnabled(true), []); const disableControls = React.useCallback( () => setControlsEnabled(false), [], ); const previousOpacityCeiling = useValue(-1); const opacityCeiling = React.useMemo( () => ceil(activeControlsOpacity), [activeControlsOpacity], ); const opacityJustChanged = React.useMemo( () => cond(eq(previousOpacityCeiling, opacityCeiling), 0, [ set(previousOpacityCeiling, opacityCeiling), 1, ]), [previousOpacityCeiling, opacityCeiling], ); const toggleControls = React.useMemo( () => [ cond( and(eq(opacityJustChanged, 1), eq(opacityCeiling, 0)), call([], disableControls), ), cond( and(eq(opacityJustChanged, 1), eq(opacityCeiling, 1)), call([], enableControls), ), ], [opacityJustChanged, opacityCeiling, disableControls, enableControls], ); /* ===== END FADE CONTROL ANIMATION ===== */ const mediaDimensions = mediaInfo.dimensions; const screenDimensions = useSelector(derivedDimensionsInfoSelector); const frame = React.useMemo( () => ({ width: screenDimensions.width, height: screenDimensions.safeAreaHeight, }), [screenDimensions], ); const mediaDisplayDimensions = React.useMemo(() => { let { height: maxHeight, width: maxWidth } = frame; if (maxHeight > maxWidth) { maxHeight -= 100; } else { maxWidth -= 100; } if ( mediaDimensions.height < maxHeight && mediaDimensions.width < maxWidth ) { return mediaDimensions; } const heightRatio = maxHeight / mediaDimensions.height; const widthRatio = maxWidth / mediaDimensions.width; if (heightRatio < widthRatio) { return { height: maxHeight, width: mediaDimensions.width * heightRatio, }; } else { return { width: maxWidth, height: mediaDimensions.height * widthRatio, }; } }, [frame, mediaDimensions]); const centerX = useValue(frame.width / 2); const centerY = useValue(frame.height / 2 + screenDimensions.topInset); const frameWidth = useValue(frame.width); const frameHeight = useValue(frame.height); const imageWidth = useValue(mediaDisplayDimensions.width); const imageHeight = useValue(mediaDisplayDimensions.height); React.useEffect(() => { const { width: frameW, height: frameH } = frame; const { topInset } = screenDimensions; frameWidth.setValue(frameW); frameHeight.setValue(frameH); centerX.setValue(frameW / 2); centerY.setValue(frameH / 2 + topInset); const { width, height } = mediaDisplayDimensions; imageWidth.setValue(width); imageHeight.setValue(height); }, [ screenDimensions, frame, mediaDisplayDimensions, frameWidth, frameHeight, centerX, centerY, imageWidth, imageHeight, ]); const left = React.useMemo( () => sub(centerX, divide(imageWidth, 2)), [centerX, imageWidth], ); const top = React.useMemo( () => sub(centerY, divide(imageHeight, 2)), [centerY, imageHeight], ); const { initialCoordinates } = props.route.params; const initialScale = React.useMemo( () => divide(initialCoordinates.width, imageWidth), [initialCoordinates, imageWidth], ); const initialTranslateX = React.useMemo( () => sub( initialCoordinates.x + initialCoordinates.width / 2, add(left, divide(imageWidth, 2)), ), [initialCoordinates, left, imageWidth], ); const initialTranslateY = React.useMemo( () => sub( initialCoordinates.y + initialCoordinates.height / 2, add(top, divide(imageHeight, 2)), ), [initialCoordinates, top, imageHeight], ); // The all-important outputs const curScale = useValue(1); const curX = useValue(0); const curY = useValue(0); const curBackdropOpacity = useValue(1); const progressiveOpacity = React.useMemo( () => max( min( sub(1, abs(divide(curX, frameWidth))), sub(1, abs(divide(curY, frameHeight))), ), 0, ), [curX, curY, frameWidth, frameHeight], ); const updates = React.useMemo( () => [toggleControls, set(curBackdropOpacity, progressiveOpacity)], [curBackdropOpacity, progressiveOpacity, toggleControls], ); const updatedScale = React.useMemo( () => [updates, curScale], [updates, curScale], ); const updatedCurX = React.useMemo(() => [updates, curX], [updates, curX]); const updatedCurY = React.useMemo(() => [updates, curY], [updates, curY]); const updatedBackdropOpacity = React.useMemo( () => [updates, curBackdropOpacity], [updates, curBackdropOpacity], ); const updatedActiveControlsOpacity = React.useMemo( () => block([updates, activeControlsOpacity]), [updates, activeControlsOpacity], ); const overlayContext = React.useContext(OverlayContext); invariant(overlayContext, 'VideoPlaybackModal should have OverlayContext'); const navigationProgress = overlayContext.position; const reverseNavigationProgress = React.useMemo( () => sub(1, navigationProgress), [navigationProgress], ); const dismissalButtonOpacity = interpolateNode(updatedBackdropOpacity, { inputRange: [0.95, 1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const controlsOpacity = multiply( navigationProgress, dismissalButtonOpacity, updatedActiveControlsOpacity, ); const scale = React.useMemo( () => add( multiply(reverseNavigationProgress, initialScale), multiply(navigationProgress, updatedScale), ), [reverseNavigationProgress, initialScale, navigationProgress, updatedScale], ); const x = React.useMemo( () => add( multiply(reverseNavigationProgress, initialTranslateX), multiply(navigationProgress, updatedCurX), ), [ reverseNavigationProgress, initialTranslateX, navigationProgress, updatedCurX, ], ); const y = React.useMemo( () => add( multiply(reverseNavigationProgress, initialTranslateY), multiply(navigationProgress, updatedCurY), ), [ reverseNavigationProgress, initialTranslateY, navigationProgress, updatedCurY, ], ); const backdropOpacity = React.useMemo( () => multiply(navigationProgress, updatedBackdropOpacity), [navigationProgress, updatedBackdropOpacity], ); const imageContainerOpacity = React.useMemo( () => interpolateNode(navigationProgress, { inputRange: [0, 0.1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }), [navigationProgress], ); const { verticalBounds } = props.route.params; const videoContainerStyle = React.useMemo(() => { const { height, width } = mediaDisplayDimensions; const { height: frameH, width: frameW } = frame; return { height, width, marginTop: (frameH - height) / 2 + screenDimensions.topInset - verticalBounds.y, marginLeft: (frameW - width) / 2, opacity: imageContainerOpacity, transform: [{ translateX: x }, { translateY: y }, { scale: scale }], }; }, [ mediaDisplayDimensions, frame, screenDimensions.topInset, verticalBounds.y, imageContainerOpacity, x, y, scale, ]); const styles = useStyles(unboundStyles); const [paused, setPaused] = useState(false); const [percentElapsed, setPercentElapsed] = useState(0); const [spinnerVisible, setSpinnerVisible] = useState(true); const [timeElapsed, setTimeElapsed] = useState('0:00'); const [totalDuration, setTotalDuration] = useState('0:00'); - const videoRef = React.useRef(); + const videoRef = React.useRef(); const backgroundedOrInactive = useIsAppBackgroundedOrInactive(); React.useEffect(() => { if (backgroundedOrInactive) { setPaused(true); controlsShowing.setValue(1); } }, [backgroundedOrInactive, controlsShowing]); const { navigation } = props; const togglePlayback = React.useCallback(() => { setPaused(!paused); }, [paused]); const resetVideo = React.useCallback(() => { invariant(videoRef.current, 'videoRef.current should be set in resetVideo'); videoRef.current.seek(0); }, []); const progressCallback = React.useCallback( (res: ReactNativeVideoOnProgressData) => { setTimeElapsed(formatDuration(res.currentTime)); setTotalDuration(formatDuration(res.seekableDuration)); setPercentElapsed( Math.ceil((res.currentTime / res.seekableDuration) * 100), ); }, [], ); const readyForDisplayCallback = React.useCallback(() => { setSpinnerVisible(false); }, []); const statusBar = overlayContext.isDismissing ? null : (