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 : (
);
const backdropStyle = React.useMemo(
() => ({ opacity: backdropOpacity }),
[backdropOpacity],
);
const contentContainerStyle = React.useMemo(() => {
const fullScreenHeight = screenDimensions.height;
const bottom = fullScreenHeight - verticalBounds.y - verticalBounds.height;
// margin will clip, but padding won't
const verticalStyle = overlayContext.isDismissing
? { marginTop: verticalBounds.y, marginBottom: bottom }
: { paddingTop: verticalBounds.y, paddingBottom: bottom };
return [styles.contentContainer, verticalStyle];
}, [
screenDimensions.height,
verticalBounds.y,
verticalBounds.height,
overlayContext.isDismissing,
styles.contentContainer,
]);
let controls;
if (videoSource) {
controls = (
{timeElapsed} / {totalDuration}
);
}
let spinner;
if (spinnerVisible) {
spinner = (
);
}
let videoPlayer;
if (videoSource) {
videoPlayer = (
);
}
return (
{statusBar}
{spinner}
{videoPlayer}
{controls}
);
}
const unboundStyles = {
expand: {
flex: 1,
},
backgroundVideo: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
},
footer: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(52,52,52,0.6)',
height: 76,
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 8,
},
header: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
},
playPauseButton: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
closeButton: {
paddingTop: 10,
paddingRight: 20,
justifyContent: 'flex-end',
alignItems: 'center',
flexDirection: 'row',
height: 100,
},
progressBar: {
flex: 1,
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
paddingRight: 10,
display: 'flex',
flexDirection: 'row',
},
progressCircle: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
top: 0,
bottom: 0,
left: 0,
right: 0,
},
iconButton: {
marginHorizontal: 10,
color: 'white',
},
durationText: {
color: 'white',
fontSize: 11,
width: 70,
},
backdrop: {
backgroundColor: 'black',
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0,
},
controls: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
top: 0,
},
contentContainer: {
flex: 1,
overflow: 'hidden',
},
fill: {
flex: 1,
},
};
export default VideoPlaybackModal;
diff --git a/native/navigation/deep-links-context-provider.react.js b/native/navigation/deep-links-context-provider.react.js
index 5e04f311f..c019de53a 100644
--- a/native/navigation/deep-links-context-provider.react.js
+++ b/native/navigation/deep-links-context-provider.react.js
@@ -1,139 +1,139 @@
// @flow
import { useNavigation } from '@react-navigation/native';
import * as Application from 'expo-application';
import * as React from 'react';
import { Linking, Platform } from 'react-native';
import {
verifyInviteLink,
verifyInviteLinkActionTypes,
} from 'lib/actions/link-actions.js';
import {
parseInstallReferrerFromInviteLinkURL,
parseDataFromDeepLink,
type ParsedDeepLinkData,
} from 'lib/facts/links.js';
import { isLoggedIn } from 'lib/selectors/user-selectors.js';
import type { SetState } from 'lib/types/hook-types.js';
import {
useDispatchActionPromise,
useServerCall,
} from 'lib/utils/action-utils.js';
import {
InviteLinkModalRouteName,
SecondaryDeviceQRCodeScannerRouteName,
} from './route-names.js';
import { useSelector } from '../redux/redux-utils.js';
import { useOnFirstLaunchEffect } from '../utils/hooks.js';
type DeepLinksContextType = {
+setCurrentLinkUrl: SetState,
};
const defaultContext = {
setCurrentLinkUrl: () => {},
};
const DeepLinksContext: React.Context =
React.createContext(defaultContext);
type Props = {
+children: React.Node,
};
function DeepLinksContextProvider(props: Props): React.Node {
const { children } = props;
- const [currentLink, setCurrentLink] = React.useState(null);
+ const [currentLink, setCurrentLink] = React.useState(null);
React.useEffect(() => {
// This listener listens for an event where a user clicked a link when the
// app was running
const subscription = Linking.addEventListener('url', ({ url }) =>
setCurrentLink(url),
);
// We're also checking if the app was opened by using a link.
// In that case the listener won't be called and we're instead checking
// if the initial URL is set.
(async () => {
const initialURL = await Linking.getInitialURL();
if (initialURL) {
setCurrentLink(initialURL);
}
})();
return () => {
subscription.remove();
};
}, []);
const checkInstallReferrer = React.useCallback(async () => {
if (Platform.OS !== 'android') {
return;
}
const installReferrer = await Application.getInstallReferrerAsync();
if (!installReferrer) {
return;
}
const linkSecret = parseInstallReferrerFromInviteLinkURL(installReferrer);
if (linkSecret) {
setCurrentLink(linkSecret);
}
}, []);
useOnFirstLaunchEffect('ANDROID_REFERRER', checkInstallReferrer);
const loggedIn = useSelector(isLoggedIn);
const dispatchActionPromise = useDispatchActionPromise();
const validateLink = useServerCall(verifyInviteLink);
const navigation = useNavigation();
React.useEffect(() => {
(async () => {
if (!loggedIn || !currentLink) {
return;
}
// We're setting this to null so that we ensure that each link click
// results in at most one validation and navigation.
setCurrentLink(null);
const parsedData: ParsedDeepLinkData = parseDataFromDeepLink(currentLink);
if (!parsedData) {
return;
}
if (parsedData.type === 'invite-link') {
const { secret } = parsedData.data;
const validateLinkPromise = validateLink({ secret });
dispatchActionPromise(verifyInviteLinkActionTypes, validateLinkPromise);
const result = await validateLinkPromise;
if (result.status === 'already_joined') {
return;
}
navigation.navigate<'InviteLinkModal'>({
name: InviteLinkModalRouteName,
params: {
invitationDetails: result,
secret,
},
});
} else if (parsedData.type === 'qr-code') {
navigation.navigate(SecondaryDeviceQRCodeScannerRouteName);
}
})();
}, [currentLink, dispatchActionPromise, loggedIn, navigation, validateLink]);
const contextValue = React.useMemo(
() => ({
setCurrentLinkUrl: setCurrentLink,
}),
[],
);
return (
{children}
);
}
export { DeepLinksContext, DeepLinksContextProvider };
diff --git a/native/navigation/disconnected-bar.react.js b/native/navigation/disconnected-bar.react.js
index d3ff4ef1d..42f7c8c21 100644
--- a/native/navigation/disconnected-bar.react.js
+++ b/native/navigation/disconnected-bar.react.js
@@ -1,103 +1,103 @@
// @flow
import * as React from 'react';
import { Text, Platform, Animated, Easing } from 'react-native';
import {
useDisconnectedBar,
useShouldShowDisconnectedBar,
} from 'lib/hooks/disconnected-bar.js';
import { useStyles } from '../themes/colors.js';
const expandedHeight = Platform.select({
android: 29.5,
default: 27,
});
const timingConfig = {
useNativeDriver: false,
duration: 200,
easing: Easing.inOut(Easing.ease),
};
type Props = {
+visible: boolean,
};
function DisconnectedBar(props: Props): React.Node {
const { shouldShowDisconnectedBar } = useShouldShowDisconnectedBar();
- const showingRef = React.useRef();
+ const showingRef = React.useRef();
if (!showingRef.current) {
showingRef.current = new Animated.Value(shouldShowDisconnectedBar ? 1 : 0);
}
const showing = showingRef.current;
const { visible } = props;
const changeShowing = React.useCallback(
(toState: boolean) => {
const toValue = toState ? 1 : 0;
if (!visible) {
showing.setValue(toValue);
return;
}
Animated.timing(showing, {
...timingConfig,
toValue,
}).start();
},
[visible, showing],
);
const barCause = useDisconnectedBar(changeShowing);
const heightStyle = React.useMemo(
() => ({
height: showing.interpolate({
inputRange: [0, 1],
outputRange: ([0, expandedHeight]: number[]), // Flow...
}),
}),
[showing],
);
const styles = useStyles(unboundStyles);
const text = barCause === 'disconnected' ? 'DISCONNECTED' : 'CONNECTING…';
const viewStyle =
barCause === 'disconnected' ? styles.disconnected : styles.connecting;
const textStyle =
barCause === 'disconnected'
? styles.disconnectedText
: styles.connectingText;
return (
{text}
);
}
const unboundStyles = {
disconnected: {
backgroundColor: '#CC0000',
overflow: 'hidden',
},
connecting: {
backgroundColor: 'disconnectedBarBackground',
overflow: 'hidden',
},
disconnectedText: {
color: 'white',
fontSize: 14,
padding: 5,
textAlign: 'center',
},
connectingText: {
color: 'panelForegroundLabel',
fontSize: 14,
padding: 5,
textAlign: 'center',
},
};
export default DisconnectedBar;
diff --git a/native/navigation/navigation-handler.react.js b/native/navigation/navigation-handler.react.js
index 4dcc7695f..1278d710f 100644
--- a/native/navigation/navigation-handler.react.js
+++ b/native/navigation/navigation-handler.react.js
@@ -1,87 +1,87 @@
// @flow
import * as React from 'react';
import { cookieSelector } from 'lib/selectors/keyserver-selectors.js';
import { isLoggedIn } from 'lib/selectors/user-selectors.js';
import { ashoatKeyserverID } from 'lib/utils/validation-utils.js';
import { logInActionType, logOutActionType } from './action-types.js';
import ModalPruner from './modal-pruner.react.js';
import NavFromReduxHandler from './nav-from-redux-handler.react.js';
import { useIsAppLoggedIn } from './nav-selectors.js';
import { NavContext, type NavAction } from './navigation-context.js';
import PolicyAcknowledgmentHandler from './policy-acknowledgment-handler.react.js';
import ThreadScreenTracker from './thread-screen-tracker.react.js';
import DevTools from '../redux/dev-tools.react.js';
import { useSelector } from '../redux/redux-utils.js';
import { usePersistedStateLoaded } from '../selectors/app-state-selectors.js';
const NavigationHandler: React.ComponentType<{}> = React.memo<{}>(
function NavigationHandler() {
const navContext = React.useContext(NavContext);
const persistedStateLoaded = usePersistedStateLoaded();
const devTools = __DEV__ ? : null;
if (!navContext || !persistedStateLoaded) {
if (__DEV__) {
return (
<>
{devTools}
>
);
} else {
return null;
}
}
const { dispatch } = navContext;
return (
<>
{devTools}
>
);
},
);
NavigationHandler.displayName = 'NavigationHandler';
type LogInHandlerProps = {
+dispatch: (action: NavAction) => void,
};
const LogInHandler = React.memo(function LogInHandler(
props: LogInHandlerProps,
) {
const { dispatch } = props;
const hasCurrentUserInfo = useSelector(isLoggedIn);
const cookie = useSelector(cookieSelector(ashoatKeyserverID));
const hasUserCookie = !!(cookie && cookie.startsWith('user='));
const loggedIn = hasCurrentUserInfo && hasUserCookie;
const navLoggedIn = useIsAppLoggedIn();
- const prevLoggedInRef = React.useRef();
+ const prevLoggedInRef = React.useRef();
React.useEffect(() => {
if (loggedIn === prevLoggedInRef.current) {
return;
}
prevLoggedInRef.current = loggedIn;
if (loggedIn && !navLoggedIn) {
dispatch({ type: (logInActionType: 'LOG_IN') });
} else if (!loggedIn && navLoggedIn) {
dispatch({ type: (logOutActionType: 'LOG_OUT') });
}
}, [navLoggedIn, loggedIn, dispatch]);
return null;
});
LogInHandler.displayName = 'LogInHandler';
export default NavigationHandler;
diff --git a/native/navigation/tab-bar.react.js b/native/navigation/tab-bar.react.js
index 01a84bcea..208ef0867 100644
--- a/native/navigation/tab-bar.react.js
+++ b/native/navigation/tab-bar.react.js
@@ -1,147 +1,150 @@
// @flow
import { BottomTabBar } from '@react-navigation/bottom-tabs';
import * as React from 'react';
import { Platform, View, StyleSheet } from 'react-native';
import Animated, { EasingNode } from 'react-native-reanimated';
import { useSafeArea } from 'react-native-safe-area-context';
import { useDispatch } from 'lib/utils/redux-utils.js';
-import { KeyboardContext } from '../keyboard/keyboard-state.js';
+import {
+ KeyboardContext,
+ type KeyboardState,
+} from '../keyboard/keyboard-state.js';
import { updateDimensionsActiveType } from '../redux/action-types.js';
import { useSelector } from '../redux/redux-utils.js';
import type { LayoutEvent } from '../types/react-native.js';
/* eslint-disable import/no-named-as-default-member */
const { Value, timing, interpolateNode } = Animated;
/* eslint-enable import/no-named-as-default-member */
const tabBarAnimationDuration = 200;
type Props = React.ElementConfig;
function TabBar(props: Props) {
- const tabBarVisibleRef = React.useRef();
+ const tabBarVisibleRef = React.useRef();
if (!tabBarVisibleRef.current) {
tabBarVisibleRef.current = new Value(1);
}
const tabBarVisible = tabBarVisibleRef.current;
const keyboardState = React.useContext(KeyboardContext);
const shouldHideTabBar = keyboardState?.mediaGalleryOpen;
- const prevKeyboardStateRef = React.useRef();
+ const prevKeyboardStateRef = React.useRef();
React.useEffect(() => {
prevKeyboardStateRef.current = keyboardState;
}, [keyboardState]);
const prevKeyboardState = prevKeyboardStateRef.current;
const setTabBar = React.useCallback(
(toValue: number) => {
const keyboardIsShowing = keyboardState && keyboardState.keyboardShowing;
const keyboardWasShowing =
prevKeyboardState && prevKeyboardState.keyboardShowing;
if (keyboardIsShowing === keyboardWasShowing) {
tabBarVisible.setValue(toValue);
return;
}
timing(tabBarVisible, {
toValue,
duration: tabBarAnimationDuration,
easing: EasingNode.inOut(EasingNode.ease),
}).start();
},
[keyboardState, prevKeyboardState, tabBarVisible],
);
- const prevShouldHideTabBarRef = React.useRef(false);
+ const prevShouldHideTabBarRef = React.useRef(false);
React.useEffect(() => {
const prevShouldHideTabBar = prevShouldHideTabBarRef.current;
if (shouldHideTabBar && !prevShouldHideTabBar) {
setTabBar(0);
} else if (!shouldHideTabBar && prevShouldHideTabBar) {
setTabBar(1);
}
prevShouldHideTabBarRef.current = shouldHideTabBar;
}, [shouldHideTabBar, setTabBar]);
const reduxTabBarHeight = useSelector(state => state.dimensions.tabBarHeight);
const dispatch = useDispatch();
const setReduxTabBarHeight = React.useCallback(
(height: number) => {
if (height === reduxTabBarHeight) {
return;
}
dispatch({
type: updateDimensionsActiveType,
payload: { tabBarHeight: height },
});
},
[reduxTabBarHeight, dispatch],
);
const [tabBarHeight, setTabBarHeight] = React.useState(0);
const insets = useSafeArea();
const handleLayout = React.useCallback(
(e: LayoutEvent) => {
const rawHeight = Math.round(e.nativeEvent.layout.height);
if (rawHeight > 100 || rawHeight <= 0) {
return;
}
if (Platform.OS === 'android') {
setTabBarHeight(rawHeight);
}
const height = rawHeight - insets.bottom;
if (height > 0) {
setReduxTabBarHeight(height);
}
},
[setTabBarHeight, setReduxTabBarHeight, insets],
);
const containerHeight = React.useMemo(
() =>
interpolateNode(tabBarVisible, {
inputRange: [0, 1],
outputRange: [0, tabBarHeight],
}),
[tabBarVisible, tabBarHeight],
);
const containerStyle = React.useMemo(
() => ({
height: tabBarHeight ? containerHeight : undefined,
...styles.container,
}),
[tabBarHeight, containerHeight],
);
if (Platform.OS !== 'android') {
return (
);
}
return (
);
}
const styles = StyleSheet.create({
container: {
bottom: 0,
left: 0,
right: 0,
},
});
// This is a render prop, not a component
// eslint-disable-next-line react/display-name
const tabBar = (props: Props): React.Node => ;
export { tabBarAnimationDuration, tabBar };
diff --git a/native/profile/keyserver-selection-bottom-sheet.react.js b/native/profile/keyserver-selection-bottom-sheet.react.js
index 2a1b9ce89..68e1aaa1f 100644
--- a/native/profile/keyserver-selection-bottom-sheet.react.js
+++ b/native/profile/keyserver-selection-bottom-sheet.react.js
@@ -1,242 +1,243 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { View, Text } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { removeKeyserverActionType } from 'lib/actions/keyserver-actions.js';
import type { KeyserverInfo } from 'lib/types/keyserver-types.js';
import type { GlobalAccountUserInfo } from 'lib/types/user-types.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { BottomSheetContext } from '../bottom-sheet/bottom-sheet-provider.react.js';
import BottomSheet from '../bottom-sheet/bottom-sheet.react.js';
import Button from '../components/button.react.js';
import CommIcon from '../components/comm-icon.react.js';
import Pill from '../components/pill.react.js';
import StatusIndicator from '../components/status-indicator.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 Alert from '../utils/alert.js';
export type KeyserverSelectionBottomSheetParams = {
+keyserverAdminUserInfo: GlobalAccountUserInfo,
+keyserverInfo: KeyserverInfo,
};
// header + paddingTop + paddingBottom + marginBottom
const keyserverHeaderHeight = 84 + 16 + 16 + 24;
type Props = {
+navigation: RootNavigationProp<'KeyserverSelectionBottomSheet'>,
+route: NavigationRoute<'KeyserverSelectionBottomSheet'>,
};
function KeyserverSelectionBottomSheet(props: Props): React.Node {
const {
navigation,
route: {
params: { keyserverAdminUserInfo, keyserverInfo },
},
} = props;
const { goBack } = navigation;
const bottomSheetContext = React.useContext(BottomSheetContext);
invariant(bottomSheetContext, 'bottomSheetContext should be set');
const { setContentHeight } = bottomSheetContext;
- const removeKeyserverContainerRef = React.useRef();
+ const removeKeyserverContainerRef =
+ React.useRef>();
const bottomSheetRef = React.useRef();
const colors = useColors();
const styles = useStyles(unboundStyles);
const insets = useSafeAreaInsets();
const onLayout = React.useCallback(() => {
removeKeyserverContainerRef.current?.measure(
(x, y, width, height, pageX, pageY) => {
if (
height === null ||
height === undefined ||
pageY === null ||
pageY === undefined
) {
return;
}
setContentHeight(height + keyserverHeaderHeight + insets.bottom);
},
);
}, [insets.bottom, setContentHeight]);
const cloudIcon = React.useMemo(
() => (
),
[colors.panelForegroundLabel],
);
const onPressDisconnectKeyserver = React.useCallback(() => {
// TODO: update this function when we have a way to
// disconnect from a keyserver
Alert.alert(
'Disconnecting from a keyserver is still not ready.',
'Please come back later.',
[{ text: 'OK' }],
);
}, []);
const dispatch = useDispatch();
const onDeleteKeyserver = React.useCallback(() => {
dispatch({
type: removeKeyserverActionType,
payload: {
keyserverAdminUserID: keyserverAdminUserInfo.id,
},
});
bottomSheetRef.current?.close();
}, [dispatch, keyserverAdminUserInfo.id]);
const onPressRemoveKeyserver = React.useCallback(() => {
Alert.alert(
'Delete keyserver',
'Are you sure you want to delete this keyserver from your keyserver ' +
'list? You will still remain in the associated communities.',
[
{
text: 'Delete',
style: 'destructive',
onPress: onDeleteKeyserver,
},
{
text: 'Cancel',
style: 'cancel',
},
],
);
}, [onDeleteKeyserver]);
const removeKeyserver = React.useMemo(() => {
if (keyserverInfo.connection.status !== 'connected') {
return (
<>
You may delete offline keyservers from your keyserver list. When you
delete a keyserver, you will still remain in the associated
communities.
Any messages or content you have previously sent will remain on the
keyserver’s communities after disconnecting or deleting.
>
);
}
return (
<>
Disconnecting from this keyserver will remove you from its associated
communities.
Any messages or content you have previously sent will remain on the
keyserver.
>
);
}, [
keyserverInfo.connection.status,
onPressDisconnectKeyserver,
onPressRemoveKeyserver,
styles.keyserverRemoveText,
styles.removeButtonContainer,
styles.removeButtonText,
]);
return (
{keyserverInfo.urlPrefix}
{removeKeyserver}
);
}
const unboundStyles = {
container: {
paddingHorizontal: 16,
},
keyserverDetailsContainer: {
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
backgroundColor: 'modalAccentBackground',
marginBottom: 24,
borderRadius: 8,
},
keyserverHeaderContainer: {
flexDirection: 'row',
alignItems: 'center',
},
statusIndicatorContainer: {
marginLeft: 8,
},
keyserverURLText: {
color: 'modalForegroundLabel',
marginTop: 8,
},
keyserverRemoveText: {
color: 'modalForegroundLabel',
marginBottom: 24,
},
removeButtonContainer: {
backgroundColor: 'vibrantRedButton',
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
},
removeButtonText: {
color: 'floatingButtonLabel',
},
};
export default KeyserverSelectionBottomSheet;
diff --git a/native/profile/secondary-device-qr-code-scanner.react.js b/native/profile/secondary-device-qr-code-scanner.react.js
index bdb9b68e3..43d25bba7 100644
--- a/native/profile/secondary-device-qr-code-scanner.react.js
+++ b/native/profile/secondary-device-qr-code-scanner.react.js
@@ -1,132 +1,132 @@
// @flow
import { useNavigation } from '@react-navigation/native';
import { BarCodeScanner, type BarCodeEvent } from 'expo-barcode-scanner';
import * as React from 'react';
import { View } from 'react-native';
import { parseDataFromDeepLink } from 'lib/facts/links.js';
import type { ProfileNavigationProp } from './profile.react.js';
import type { NavigationRoute } from '../navigation/route-names.js';
import { useStyles } from '../themes/colors.js';
import Alert from '../utils/alert.js';
const barCodeTypes = [BarCodeScanner.Constants.BarCodeType.qr];
type Props = {
+navigation: ProfileNavigationProp<'SecondaryDeviceQRCodeScanner'>,
+route: NavigationRoute<'SecondaryDeviceQRCodeScanner'>,
};
// eslint-disable-next-line no-unused-vars
function SecondaryDeviceQRCodeScanner(props: Props): React.Node {
- const [hasPermission, setHasPermission] = React.useState(null);
+ const [hasPermission, setHasPermission] = React.useState(null);
const [scanned, setScanned] = React.useState(false);
const styles = useStyles(unboundStyles);
const navigation = useNavigation();
React.useEffect(() => {
(async () => {
const { status } = await BarCodeScanner.requestPermissionsAsync();
setHasPermission(status === 'granted');
if (status !== 'granted') {
Alert.alert(
'No access to camera',
'Please allow Comm to access your camera in order to scan the QR code.',
[{ text: 'OK' }],
);
navigation.goBack();
}
})();
}, [navigation]);
const onConnect = React.useCallback((barCodeEvent: BarCodeEvent) => {
const { data } = barCodeEvent;
const parsedData = parseDataFromDeepLink(data);
const keysMatch = parsedData?.data?.keys;
if (!parsedData || !keysMatch) {
Alert.alert(
'Scan failed',
'QR code does not contain a valid pair of keys.',
[{ text: 'OK' }],
);
return;
}
const keys = JSON.parse(decodeURIComponent(keysMatch));
Alert.alert(
'Scan successful',
`QR code contains the following keys: ${JSON.stringify(keys)}`,
[{ text: 'OK' }],
);
}, []);
const onCancelScan = React.useCallback(() => setScanned(false), []);
const handleBarCodeScanned = React.useCallback(
(barCodeEvent: BarCodeEvent) => {
setScanned(true);
Alert.alert(
'Connect with this device?',
'Are you sure you want to allow this device to log in to your account?',
[
{
text: 'Cancel',
style: 'cancel',
onPress: onCancelScan,
},
{
text: 'Connect',
onPress: () => onConnect(barCodeEvent),
},
],
{ cancelable: false },
);
},
[onCancelScan, onConnect],
);
if (hasPermission === null) {
return ;
}
// Note: According to the BarCodeScanner Expo docs, we should adhere to two
// guidances when using the BarCodeScanner:
// 1. We should specify the potential barCodeTypes we want to scan for to
// minimize battery usage.
// 2. We should set the onBarCodeScanned callback to undefined if it scanned
// in order to 'pause' the scanner from continuing to scan while we
// process the data from the scan.
// See: https://docs.expo.io/versions/latest/sdk/bar-code-scanner
return (
);
}
const unboundStyles = {
container: {
flex: 1,
flexDirection: 'column',
justifyContent: 'center',
},
scanner: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
};
export default SecondaryDeviceQRCodeScanner;
diff --git a/native/redux/dimensions-updater.react.js b/native/redux/dimensions-updater.react.js
index e8b00a4ed..894b1df88 100644
--- a/native/redux/dimensions-updater.react.js
+++ b/native/redux/dimensions-updater.react.js
@@ -1,112 +1,112 @@
// @flow
import * as React from 'react';
import {
initialWindowMetrics,
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context';
import type { Dimensions } from 'lib/types/media-types.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { updateDimensionsActiveType } from './action-types.js';
import { useSelector } from './redux-utils.js';
import {
addKeyboardShowListener,
addKeyboardDismissListener,
removeKeyboardListener,
rnsacThinksAndroidKeyboardResizesFrame,
} from '../keyboard/keyboard.js';
type BaseDimensionsInfo = {
...Dimensions,
+topInset: number,
+bottomInset: number,
};
export type DimensionsInfo = {
...BaseDimensionsInfo,
+tabBarHeight: number,
+rotated: boolean,
};
type Metrics = {
+frame: { +x: number, +y: number, +width: number, +height: number },
+insets: { +top: number, +left: number, +right: number, +bottom: number },
};
function dimensionsUpdateFromMetrics(metrics: ?Metrics): BaseDimensionsInfo {
if (!metrics) {
// This happens when the app gets booted to run a background task
return { height: 0, width: 0, topInset: 0, bottomInset: 0 };
}
return {
height: metrics.frame.height,
width: metrics.frame.width,
topInset: metrics.insets.top,
bottomInset: metrics.insets.bottom,
};
}
const defaultDimensionsInfo = {
...(dimensionsUpdateFromMetrics(initialWindowMetrics): BaseDimensionsInfo),
tabBarHeight: 50,
rotated: false,
};
const defaultIsPortrait =
defaultDimensionsInfo.height >= defaultDimensionsInfo.width;
function DimensionsUpdater(): null {
const dimensions = useSelector(state => state.dimensions);
const dispatch = useDispatch();
const frame = useSafeAreaFrame();
const insets = useSafeAreaInsets();
- const keyboardShowingRef = React.useRef();
+ const keyboardShowingRef = React.useRef();
const keyboardShow = React.useCallback(() => {
keyboardShowingRef.current = true;
}, []);
const keyboardDismiss = React.useCallback(() => {
keyboardShowingRef.current = false;
}, []);
React.useEffect(() => {
if (!rnsacThinksAndroidKeyboardResizesFrame) {
return undefined;
}
const showListener = addKeyboardShowListener(keyboardShow);
const dismissListener = addKeyboardDismissListener(keyboardDismiss);
return () => {
removeKeyboardListener(showListener);
removeKeyboardListener(dismissListener);
};
}, [keyboardShow, keyboardDismiss]);
const keyboardShowing = keyboardShowingRef.current;
React.useEffect(() => {
if (keyboardShowing) {
return;
}
let updates: Partial<$ReadOnly<{ ...DimensionsInfo }>> =
dimensionsUpdateFromMetrics({ frame, insets });
if (updates.height && updates.width && updates.height !== updates.width) {
updates = {
...updates,
rotated: updates.width > updates.height === defaultIsPortrait,
};
}
for (const key in updates) {
if (updates[key] === dimensions[key]) {
continue;
}
dispatch({
type: updateDimensionsActiveType,
payload: updates,
});
return;
}
}, [keyboardShowing, dimensions, dispatch, frame, insets]);
return null;
}
export { defaultDimensionsInfo, DimensionsUpdater };
diff --git a/native/root.react.js b/native/root.react.js
index 1f9ec5a1f..2204ac200 100644
--- a/native/root.react.js
+++ b/native/root.react.js
@@ -1,358 +1,368 @@
// @flow
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type {
PossiblyStaleNavigationState,
UnsafeContainerActionEvent,
+ GenericNavigationAction,
} from '@react-navigation/core';
import { useReduxDevToolsExtension } from '@react-navigation/devtools';
import { NavigationContainer } from '@react-navigation/native';
import * as SplashScreen from 'expo-splash-screen';
import invariant from 'invariant';
import * as React from 'react';
import { Platform, UIManager, StyleSheet } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import Orientation from 'react-native-orientation-locker';
import {
SafeAreaProvider,
initialWindowMetrics,
} from 'react-native-safe-area-context';
import { Provider } from 'react-redux';
import { PersistGate as ReduxPersistGate } from 'redux-persist/es/integration/react.js';
import { ChatMentionContextProvider } from 'lib/components/chat-mention-provider.react.js';
import { EditUserAvatarProvider } from 'lib/components/edit-user-avatar-provider.react.js';
import { ENSCacheProvider } from 'lib/components/ens-cache-provider.react.js';
import IntegrityHandler from 'lib/components/integrity-handler.react.js';
import { MediaCacheProvider } from 'lib/components/media-cache-provider.react.js';
import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js';
import { actionLogger } from 'lib/utils/action-logger.js';
import { RegistrationContextProvider } from './account/registration/registration-context-provider.react.js';
import NativeEditThreadAvatarProvider from './avatars/native-edit-thread-avatar-provider.react.js';
import BackupHandler from './backup/backup-handler.js';
import { BottomSheetProvider } from './bottom-sheet/bottom-sheet-provider.react.js';
import ChatContextProvider from './chat/chat-context-provider.react.js';
import MessageEditingContextProvider from './chat/message-editing-context-provider.react.js';
import { FeatureFlagsProvider } from './components/feature-flags-provider.react.js';
import PersistedStateGate from './components/persisted-state-gate.js';
import VersionSupportedChecker from './components/version-supported.react.js';
import ConnectedStatusBar from './connected-status-bar.react.js';
import { SQLiteDataHandler } from './data/sqlite-data-handler.js';
import ErrorBoundary from './error-boundary.react.js';
import InputStateContainer from './input/input-state-container.react.js';
import LifecycleHandler from './lifecycle/lifecycle-handler.react.js';
import MarkdownContextProvider from './markdown/markdown-context-provider.react.js';
import { filesystemMediaCache } from './media/media-cache.js';
import { DeepLinksContextProvider } from './navigation/deep-links-context-provider.react.js';
import { defaultNavigationState } from './navigation/default-state.js';
import DisconnectedBarVisibilityHandler from './navigation/disconnected-bar-visibility-handler.react.js';
import { setGlobalNavContext } from './navigation/icky-global.js';
-import { NavContext } from './navigation/navigation-context.js';
+import {
+ NavContext,
+ type NavContextType,
+} from './navigation/navigation-context.js';
import NavigationHandler from './navigation/navigation-handler.react.js';
import { validNavState } from './navigation/navigation-utils.js';
import OrientationHandler from './navigation/orientation-handler.react.js';
import { navStateAsyncStorageKey } from './navigation/persistance.js';
import RootNavigator from './navigation/root-navigator.react.js';
import ConnectivityUpdater from './redux/connectivity-updater.react.js';
import { DimensionsUpdater } from './redux/dimensions-updater.react.js';
import { getPersistor } from './redux/persist.js';
import { store } from './redux/redux-setup.js';
import { useSelector } from './redux/redux-utils.js';
import { RootContext } from './root-context.js';
import { MessageSearchProvider } from './search/search-provider.react.js';
import Socket from './socket.react.js';
import { StaffContextProvider } from './staff/staff-context.provider.react.js';
import { useLoadCommFonts } from './themes/fonts.js';
import { DarkTheme, LightTheme } from './themes/navigation.js';
import ThemeHandler from './themes/theme-handler.react.js';
import { provider } from './utils/ethers-utils.js';
import { useTunnelbrokerInitMessage } from './utils/tunnelbroker-utils.js';
// Add custom items to expo-dev-menu
import './dev-menu.js';
import './types/message-types-validator.js';
if (Platform.OS === 'android') {
UIManager.setLayoutAnimationEnabledExperimental &&
UIManager.setLayoutAnimationEnabledExperimental(true);
}
const navInitAction = Object.freeze({ type: 'NAV/@@INIT' });
const navUnknownAction = Object.freeze({ type: 'NAV/@@UNKNOWN' });
SplashScreen.preventAutoHideAsync().catch(console.log);
function Root() {
- const navStateRef = React.useRef();
- const navDispatchRef = React.useRef();
+ const navStateRef = React.useRef();
+ const navDispatchRef =
+ React.useRef(
+ action:
+ | GenericNavigationAction
+ | (PossiblyStaleNavigationState => GenericNavigationAction),
+ ) => void>();
const navStateInitializedRef = React.useRef(false);
// We call this here to start the loading process
// We gate the UI on the fonts loading in AppNavigator
useLoadCommFonts();
- const [navContext, setNavContext] = React.useState(null);
+ const [navContext, setNavContext] = React.useState(null);
const updateNavContext = React.useCallback(() => {
if (
!navStateRef.current ||
!navDispatchRef.current ||
!navStateInitializedRef.current
) {
return;
}
const updatedNavContext = {
state: navStateRef.current,
dispatch: navDispatchRef.current,
};
setNavContext(updatedNavContext);
setGlobalNavContext(updatedNavContext);
}, []);
const [initialState, setInitialState] = React.useState(
__DEV__ ? undefined : defaultNavigationState,
);
React.useEffect(() => {
Orientation.lockToPortrait();
(async () => {
let loadedState = initialState;
if (__DEV__) {
try {
const navStateString = await AsyncStorage.getItem(
navStateAsyncStorageKey,
);
if (navStateString) {
const savedState = JSON.parse(navStateString);
if (validNavState(savedState)) {
loadedState = savedState;
}
}
} catch {}
}
if (!loadedState) {
loadedState = defaultNavigationState;
}
if (loadedState !== initialState) {
setInitialState(loadedState);
}
navStateRef.current = loadedState;
updateNavContext();
actionLogger.addOtherAction('navState', navInitAction, null, loadedState);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updateNavContext]);
const setNavStateInitialized = React.useCallback(() => {
navStateInitializedRef.current = true;
updateNavContext();
}, [updateNavContext]);
const [rootContext, setRootContext] = React.useState(() => ({
setNavStateInitialized,
}));
const detectUnsupervisedBackgroundRef = React.useCallback(
(detectUnsupervisedBackground: ?(alreadyClosed: boolean) => boolean) => {
setRootContext(prevRootContext => ({
...prevRootContext,
detectUnsupervisedBackground,
}));
},
[],
);
const frozen = useSelector(state => state.frozen);
- const queuedActionsRef = React.useRef([]);
+ const queuedActionsRef = React.useRef>([]);
const onNavigationStateChange = React.useCallback(
(state: ?PossiblyStaleNavigationState) => {
invariant(state, 'nav state should be non-null');
const prevState = navStateRef.current;
navStateRef.current = state;
updateNavContext();
const queuedActions = queuedActionsRef.current;
queuedActionsRef.current = [];
if (queuedActions.length === 0) {
queuedActions.push(navUnknownAction);
}
for (const action of queuedActions) {
actionLogger.addOtherAction('navState', action, prevState, state);
}
if (!__DEV__ || frozen) {
return;
}
(async () => {
try {
await AsyncStorage.setItem(
navStateAsyncStorageKey,
JSON.stringify(state),
);
} catch (e) {
console.log('AsyncStorage threw while trying to persist navState', e);
}
})();
},
[updateNavContext, frozen],
);
- const navContainerRef = React.useRef();
+ const navContainerRef =
+ React.useRef>();
const containerRef = React.useCallback(
(navContainer: ?React.ElementRef) => {
navContainerRef.current = navContainer;
if (navContainer && !navDispatchRef.current) {
navDispatchRef.current = navContainer.dispatch;
updateNavContext();
}
},
[updateNavContext],
);
useReduxDevToolsExtension(navContainerRef);
const navContainer = navContainerRef.current;
React.useEffect(() => {
if (!navContainer) {
return undefined;
}
return navContainer.addListener(
'__unsafe_action__',
(event: { +data: UnsafeContainerActionEvent, ... }) => {
const { action, noop } = event.data;
const navState = navStateRef.current;
if (noop) {
actionLogger.addOtherAction('navState', action, navState, navState);
return;
}
queuedActionsRef.current.push({
...action,
type: `NAV/${action.type}`,
});
},
);
}, [navContainer]);
const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme);
const theme = (() => {
if (activeTheme === 'light') {
return LightTheme;
} else if (activeTheme === 'dark') {
return DarkTheme;
}
return undefined;
})();
const tunnelbrokerInitMessage = useTunnelbrokerInitMessage();
const gated: React.Node = (
<>
>
);
let navigation;
if (initialState) {
navigation = (
);
}
return (
{gated}
{navigation}
);
}
const styles = StyleSheet.create({
app: {
flex: 1,
},
});
function AppRoot(): React.Node {
return (
);
}
export default AppRoot;
diff --git a/native/search/message-search.react.js b/native/search/message-search.react.js
index d6ea1ec9c..51d897cb5 100644
--- a/native/search/message-search.react.js
+++ b/native/search/message-search.react.js
@@ -1,227 +1,233 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { View } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import { messageListData } from 'lib/selectors/chat-selectors.js';
import { createMessageInfo } from 'lib/shared/message-utils.js';
import {
useSearchMessages,
filterChatMessageInfosForSearch,
} from 'lib/shared/search-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 SearchFooter from './search-footer.react.js';
import { MessageSearchContext } from './search-provider.react.js';
import { useHeightMeasurer } from '../chat/chat-context.js';
import type { ChatNavigationProp } from '../chat/chat.react.js';
import { MessageListContextProvider } from '../chat/message-list-types.js';
import MessageResult from '../chat/message-result.react.js';
import ListLoadingIndicator from '../components/list-loading-indicator.react.js';
import type { NavigationRoute } from '../navigation/route-names.js';
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 MessageSearchParams = {
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
};
export type MessageSearchProps = {
+navigation: ChatNavigationProp<'MessageSearch'>,
+route: NavigationRoute<'MessageSearch'>,
};
function MessageSearch(props: MessageSearchProps): React.Node {
const searchContext = React.useContext(MessageSearchContext);
invariant(searchContext, 'searchContext should be set');
const { query, clearQuery } = searchContext;
const { threadInfo } = props.route.params;
React.useEffect(() => {
return props.navigation.addListener('beforeRemove', clearQuery);
}, [props.navigation, clearQuery]);
const [lastID, setLastID] = React.useState();
- const [searchResults, setSearchResults] = React.useState([]);
+ const [searchResults, setSearchResults] = React.useState<
+ $ReadOnlyArray,
+ >([]);
const [endReached, setEndReached] = React.useState(false);
const appendSearchResults = React.useCallback(
(
newMessages: $ReadOnlyArray,
end: boolean,
queryID: number,
) => {
if (queryID !== queryIDRef.current) {
return;
}
setSearchResults(oldMessages => [...oldMessages, ...newMessages]);
setEndReached(end);
},
[],
);
const searchMessages = useSearchMessages();
const queryIDRef = React.useRef(0);
React.useEffect(() => {
setSearchResults([]);
setLastID(undefined);
setEndReached(false);
}, [query, searchMessages]);
React.useEffect(() => {
queryIDRef.current += 1;
searchMessages(
query,
threadInfo.id,
appendSearchResults,
queryIDRef.current,
lastID,
);
}, [appendSearchResults, lastID, query, searchMessages, threadInfo.id]);
const userInfos = useSelector(state => state.userStore.userInfos);
const translatedSearchResults = React.useMemo(() => {
const threadInfos = { [threadInfo.id]: threadInfo };
return searchResults
.map(rawMessageInfo =>
createMessageInfo(rawMessageInfo, null, userInfos, threadInfos),
)
.filter(Boolean);
}, [searchResults, threadInfo, userInfos]);
const chatMessageInfos = useSelector(
messageListData(threadInfo.id, translatedSearchResults),
);
const filteredChatMessageInfos = React.useMemo(() => {
const result = filterChatMessageInfosForSearch(
chatMessageInfos,
translatedSearchResults,
);
if (result && !endReached) {
return [...result, { itemType: 'loader' }];
}
return result;
}, [chatMessageInfos, endReached, translatedSearchResults]);
- const [measuredMessages, setMeasuredMessages] = React.useState([]);
+ const [measuredMessages, setMeasuredMessages] = React.useState<
+ $ReadOnlyArray,
+ >([]);
const measureMessages = useHeightMeasurer();
const measureCallback = React.useCallback(
(listDataWithHeights: $ReadOnlyArray) => {
setMeasuredMessages(listDataWithHeights);
},
[setMeasuredMessages],
);
React.useEffect(() => {
measureMessages(filteredChatMessageInfos, threadInfo, measureCallback);
}, [filteredChatMessageInfos, measureCallback, measureMessages, threadInfo]);
- const [messageVerticalBounds, setMessageVerticalBounds] = React.useState();
- const scrollViewContainerRef = React.useRef();
+ const [messageVerticalBounds, setMessageVerticalBounds] =
+ React.useState();
+ const scrollViewContainerRef = React.useRef>();
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 renderItem = React.useCallback(
({ item }: { +item: ChatMessageItemWithHeight, ... }) => {
if (item.itemType === 'loader') {
return ;
}
return (
);
},
[messageVerticalBounds, props.navigation, props.route, threadInfo],
);
const footer = React.useMemo(() => {
if (query === '') {
return ;
}
if (!endReached) {
return null;
}
if (measuredMessages.length > 0) {
return ;
}
const text =
'No results. Please try using different keywords to refine your search';
return ;
}, [query, endReached, measuredMessages.length]);
const onEndOfLoadedMessagesReached = React.useCallback(() => {
if (endReached) {
return;
}
setLastID(oldestMessageID(measuredMessages));
}, [endReached, measuredMessages, setLastID]);
const styles = useStyles(unboundStyles);
return (
);
}
function oldestMessageID(data: $ReadOnlyArray) {
for (let i = data.length - 1; i >= 0; i--) {
if (data[i].itemType === 'message' && data[i].messageInfo.id) {
return data[i].messageInfo.id;
}
}
return undefined;
}
const unboundStyles = {
content: {
height: '100%',
backgroundColor: 'panelBackground',
},
};
export default MessageSearch;
diff --git a/native/user-profile/user-profile-avatar.react.js b/native/user-profile/user-profile-avatar.react.js
index c48e85217..065217394 100644
--- a/native/user-profile/user-profile-avatar.react.js
+++ b/native/user-profile/user-profile-avatar.react.js
@@ -1,73 +1,73 @@
// @flow
import { useNavigation, useRoute } from '@react-navigation/native';
import invariant from 'invariant';
import * as React from 'react';
import { View, TouchableOpacity } from 'react-native';
import { userProfileUserInfoContainerHeight } from './user-profile-constants.js';
import UserAvatar from '../avatars/user-avatar.react.js';
import { OverlayContext } from '../navigation/overlay-context.js';
import { UserProfileAvatarModalRouteName } from '../navigation/route-names.js';
// We need to set onAvatarLayout in order to allow .measure() to be on the ref
const onAvatarLayout = () => {};
type Props = {
userID: ?string,
};
function UserProfileAvatar(props: Props): React.Node {
const { userID } = props;
const { navigate } = useNavigation();
const route = useRoute();
const overlayContext = React.useContext(OverlayContext);
- const avatarRef = React.useRef();
+ const avatarRef = React.useRef>();
const onPressAvatar = React.useCallback(() => {
invariant(overlayContext, 'UserProfileAvatar should have OverlayContext');
overlayContext.setScrollBlockingModalStatus('open');
const currentAvatarRef = avatarRef.current;
if (!currentAvatarRef) {
return;
}
currentAvatarRef.measure((x, y, width, height, pageX, pageY) => {
const coordinates = {
x: pageX,
y: pageY,
width,
height,
};
const verticalBounds = {
height: userProfileUserInfoContainerHeight,
y: pageY,
};
navigate<'UserProfileAvatarModal'>({
name: UserProfileAvatarModalRouteName,
params: {
presentedFrom: route.key,
initialCoordinates: coordinates,
verticalBounds,
userID,
},
});
});
}, [navigate, overlayContext, route.key, userID]);
return (
);
}
export default UserProfileAvatar;
diff --git a/native/user-profile/user-profile-menu-button.react.js b/native/user-profile/user-profile-menu-button.react.js
index 2e7e68f71..4e5c3cd22 100644
--- a/native/user-profile/user-profile-menu-button.react.js
+++ b/native/user-profile/user-profile-menu-button.react.js
@@ -1,148 +1,148 @@
// @flow
import { useNavigation, useRoute } from '@react-navigation/native';
import invariant from 'invariant';
import * as React from 'react';
import { View, TouchableOpacity } from 'react-native';
import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { userRelationshipStatus } from 'lib/types/relationship-types.js';
import type { ThreadInfo } from 'lib/types/thread-types.js';
import type { UserInfo } from 'lib/types/user-types';
import { userProfileMenuButtonHeight } from './user-profile-constants.js';
import SWMansionIcon from '../components/swmansion-icon.react.js';
import { OverlayContext } from '../navigation/overlay-context.js';
import { UserRelationshipTooltipModalRouteName } from '../navigation/route-names.js';
import { useStyles } from '../themes/colors.js';
// We need to set onMenuButtonLayout in order to allow .measure()
// to be on the ref
const onMenuButtonLayout = () => {};
type Props = {
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+pendingPersonalThreadUserInfo: ?UserInfo,
};
function UserProfileMenuButton(props: Props): React.Node {
const { threadInfo, pendingPersonalThreadUserInfo } = props;
const { otherUserInfo } = useRelationshipPrompt(
threadInfo,
undefined,
pendingPersonalThreadUserInfo,
);
const { navigate } = useNavigation();
const route = useRoute();
const styles = useStyles(unboundStyles);
const overlayContext = React.useContext(OverlayContext);
- const menuButtonRef = React.useRef();
+ const menuButtonRef = React.useRef>();
const visibleTooltipActionEntryIDs = React.useMemo(() => {
const result = [];
if (otherUserInfo?.relationshipStatus === userRelationshipStatus.FRIEND) {
result.push('unfriend');
result.push('block');
} else if (
otherUserInfo?.relationshipStatus ===
userRelationshipStatus.BOTH_BLOCKED ||
otherUserInfo?.relationshipStatus ===
userRelationshipStatus.BLOCKED_BY_VIEWER
) {
result.push('unblock');
} else {
result.push('block');
}
return result;
}, [otherUserInfo?.relationshipStatus]);
const onPressMenuButton = React.useCallback(() => {
invariant(
overlayContext,
'UserProfileMenuButton should have OverlayContext',
);
overlayContext.setScrollBlockingModalStatus('open');
const currentMenuButtonRef = menuButtonRef.current;
if (!currentMenuButtonRef || !otherUserInfo) {
return;
}
currentMenuButtonRef.measure((x, y, width, height, pageX, pageY) => {
const coordinates = {
x: pageX,
y: pageY,
width,
height,
};
const verticalBounds = {
height: userProfileMenuButtonHeight,
y: pageY,
};
const { relationshipStatus, ...restUserInfo } = otherUserInfo;
const relativeUserInfo = {
...restUserInfo,
isViewer: false,
};
navigate<'UserRelationshipTooltipModal'>({
name: UserRelationshipTooltipModalRouteName,
params: {
presentedFrom: route.key,
initialCoordinates: coordinates,
verticalBounds,
visibleEntryIDs: visibleTooltipActionEntryIDs,
relativeUserInfo,
tooltipButtonIcon: 'menu',
},
});
});
}, [
navigate,
otherUserInfo,
overlayContext,
route.key,
visibleTooltipActionEntryIDs,
]);
const userProfileMenuButton = React.useMemo(
() => (
),
[onPressMenuButton, styles.iconContainer, styles.moreIcon],
);
return userProfileMenuButton;
}
const unboundStyles = {
iconContainer: {
alignSelf: 'flex-end',
},
moreIcon: {
color: 'modalButtonLabel',
alignSelf: 'flex-end',
},
};
export default UserProfileMenuButton;
diff --git a/native/utils/animation-utils.js b/native/utils/animation-utils.js
index 568647884..414cecc69 100644
--- a/native/utils/animation-utils.js
+++ b/native/utils/animation-utils.js
@@ -1,258 +1,258 @@
// @flow
import * as React from 'react';
import { Platform } from 'react-native';
import { State as GestureState } from 'react-native-gesture-handler';
import Animated, {
EasingNode,
type NodeParam,
type SpringConfig,
type TimingConfig,
} from 'react-native-reanimated';
import type { Shape } from 'lib/types/core.js';
/* eslint-disable import/no-named-as-default-member */
const {
Clock,
Node,
Value,
block,
cond,
not,
and,
or,
greaterThan,
lessThan,
eq,
neq,
add,
sub,
divide,
set,
max,
startClock,
stopClock,
clockRunning,
timing,
spring,
SpringUtils,
} = Animated;
/* eslint-enable import/no-named-as-default-member */
function clamp(
value: Node,
minValue: Node | number,
maxValue: Node | number,
): Node {
return cond(
greaterThan(value, maxValue),
maxValue,
cond(greaterThan(minValue, value), minValue, value),
);
}
function delta(value: Node): Node {
const prevValue = new Value(0);
const deltaValue = new Value(0);
return block([
set(deltaValue, cond(eq(prevValue, 0), 0, sub(value, prevValue))),
set(prevValue, value),
deltaValue,
]);
}
function gestureJustStarted(state: Node): Node {
const prevValue = new Value(-1);
return cond(eq(prevValue, state), 0, [
set(prevValue, state),
eq(state, GestureState.ACTIVE),
]);
}
function gestureJustEnded(state: Node): Node {
const prevValue = new Value(-1);
return cond(eq(prevValue, state), 0, [
set(prevValue, state),
eq(state, GestureState.END),
]);
}
const defaultTimingConfig = {
duration: 250,
easing: EasingNode.out(EasingNode.ease),
};
function runTiming(
clock: Clock,
initialValue: Node | number,
finalValue: Node | number,
startStopClock: boolean = true,
config?: Shape,
): Node {
const state = {
finished: new Value(0),
position: new Value(0),
frameTime: new Value(0),
time: new Value(0),
};
const timingConfig = {
...defaultTimingConfig,
...config,
toValue: new Value(0),
};
return block([
cond(not(clockRunning(clock)), [
set(state.finished, 0),
set(state.frameTime, 0),
set(state.time, 0),
set(state.position, initialValue),
set(timingConfig.toValue, finalValue),
startStopClock ? startClock(clock) : undefined,
]),
timing(clock, state, timingConfig),
cond(state.finished, startStopClock ? stopClock(clock) : undefined),
state.position,
]);
}
const defaultSpringConfig = SpringUtils.makeDefaultConfig();
type SpringAnimationInitialState = Shape<{
+velocity: Value | number,
}>;
function runSpring(
clock: Clock,
initialValue: Node | number,
finalValue: Node | number,
startStopClock: boolean = true,
config?: Shape,
initialState?: SpringAnimationInitialState,
): Node {
const state = {
finished: new Value(0),
position: new Value(0),
velocity: new Value(0),
time: new Value(0),
};
const springConfig = {
...defaultSpringConfig,
...config,
toValue: new Value(0),
};
return block([
cond(not(clockRunning(clock)), [
set(state.finished, 0),
set(state.velocity, initialState?.velocity ?? 0),
set(state.time, 0),
set(state.position, initialValue),
set(springConfig.toValue, finalValue),
startStopClock ? startClock(clock) : undefined,
]),
spring(clock, state, springConfig),
cond(state.finished, startStopClock ? stopClock(clock) : undefined),
state.position,
]);
}
// You provide a node that performs a "ratchet",
// and this function will call it as keyboard height increases
function ratchetAlongWithKeyboardHeight(
keyboardHeight: Node,
ratchetFunction: NodeParam,
): Node {
const prevKeyboardHeightValue = new Value(-1);
const whenToUpdate = Platform.select({
// In certain situations, iOS will send multiple keyboardShows in rapid
// succession with increasing height values. Only the final value has any
// semblance of reality. I've encountered this when using the native
// password management integration
default: greaterThan(keyboardHeight, max(prevKeyboardHeightValue, 0)),
});
const whenToReset = and(
eq(keyboardHeight, 0),
greaterThan(prevKeyboardHeightValue, 0),
);
return block([
cond(
lessThan(prevKeyboardHeightValue, 0),
set(prevKeyboardHeightValue, keyboardHeight),
),
cond(or(whenToUpdate, whenToReset), ratchetFunction),
set(prevKeyboardHeightValue, keyboardHeight),
]);
}
function useReanimatedValueForBoolean(booleanValue: boolean): Value {
- const reanimatedValueRef = React.useRef();
+ const reanimatedValueRef = React.useRef();
if (!reanimatedValueRef.current) {
reanimatedValueRef.current = new Value(booleanValue ? 1 : 0);
}
const val = reanimatedValueRef.current;
React.useEffect(() => {
reanimatedValueRef.current?.setValue(booleanValue ? 1 : 0);
}, [booleanValue]);
return val;
}
// Target can be either 0 or 1. Caller handles interpolating
function animateTowards(
target: Node,
fullAnimationLength: number, // in ms
): Node {
const curValue = new Value(-1);
const prevTarget = new Value(-1);
const clock = new Clock();
const prevClockValue = new Value(0);
const curDeltaClockValue = new Value(0);
const deltaClockValue = [
set(
curDeltaClockValue,
cond(eq(prevClockValue, 0), 0, sub(clock, prevClockValue)),
),
set(prevClockValue, clock),
curDeltaClockValue,
];
const progressPerFrame = divide(deltaClockValue, fullAnimationLength);
return block([
[
cond(eq(curValue, -1), set(curValue, target)),
cond(eq(prevTarget, -1), set(prevTarget, target)),
],
cond(neq(target, prevTarget), [stopClock(clock), set(prevTarget, target)]),
cond(neq(curValue, target), [
cond(not(clockRunning(clock)), [
set(prevClockValue, 0),
startClock(clock),
]),
set(
curValue,
cond(
eq(target, 1),
add(curValue, progressPerFrame),
sub(curValue, progressPerFrame),
),
),
]),
[
cond(greaterThan(curValue, 1), set(curValue, 1)),
cond(lessThan(curValue, 0), set(curValue, 0)),
],
cond(eq(curValue, target), [stopClock(clock)]),
curValue,
]);
}
export {
clamp,
delta,
gestureJustStarted,
gestureJustEnded,
runTiming,
runSpring,
ratchetAlongWithKeyboardHeight,
useReanimatedValueForBoolean,
animateTowards,
};