[FIX] Jump to message stuck on loading animation (#4410)

This commit is contained in:
Diego Mello 2022-08-19 18:14:37 -03:00 committed by GitHub
parent e723990e82
commit 17be449d4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1013 additions and 980 deletions

View File

@ -1,73 +0,0 @@
import React, { useEffect } from 'react';
import { Modal, StyleSheet, View, PixelRatio } from 'react-native';
import Animated, {
cancelAnimation,
Extrapolate,
interpolate,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming
} from 'react-native-reanimated';
import { useTheme } from '../theme';
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
image: {
width: PixelRatio.get() * 40,
height: PixelRatio.get() * 40,
resizeMode: 'contain'
}
});
interface ILoadingProps {
visible: boolean;
}
const Loading = ({ visible }: ILoadingProps): React.ReactElement => {
const opacity = useSharedValue(0);
const scale = useSharedValue(1);
const { colors } = useTheme();
useEffect(() => {
if (visible) {
opacity.value = withTiming(1, {
duration: 200
});
scale.value = withRepeat(withSequence(withTiming(0, { duration: 1000 }), withTiming(1, { duration: 1000 })), -1);
}
return () => {
cancelAnimation(scale);
};
}, [opacity, scale, visible]);
const animatedOpacity = useAnimatedStyle(() => ({
opacity: interpolate(opacity.value, [0, 1], [0, colors.backdropOpacity], Extrapolate.CLAMP)
}));
const animatedScale = useAnimatedStyle(() => ({ transform: [{ scale: interpolate(scale.value, [0, 0.5, 1], [1, 1.1, 1]) }] }));
return (
<Modal visible={visible} transparent onRequestClose={() => {}}>
<View style={styles.container} testID='loading'>
<Animated.View
style={[
{
...StyleSheet.absoluteFillObject,
backgroundColor: colors.backdropColor
},
animatedOpacity
]}
/>
<Animated.Image source={require('../static/images/logo.png')} style={[styles.image, animatedScale]} />
</View>
</Modal>
);
};
export default Loading;

View File

@ -0,0 +1,74 @@
import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
import React from 'react';
import Loading, { sendLoadingEvent, LOADING_BUTTON_TEST_ID, LOADING_IMAGE_TEST_ID, LOADING_TEST_ID } from '.';
const Render = () => <Loading />;
const getByTestIdAndThrow = (fn: Function, testID: string) =>
expect(() => fn(testID)).toThrow(`Unable to find an element with testID: ${testID}`);
describe('Loading', () => {
it('starts invisible and shows/hides when event is received', async () => {
const { getByTestId } = render(<Render />);
getByTestIdAndThrow(getByTestId, LOADING_TEST_ID);
// receive event and expect loading to be rendered
act(() => sendLoadingEvent({ visible: true }));
await waitFor(() => {
expect(() => getByTestId(LOADING_TEST_ID));
});
expect(() => getByTestId(LOADING_IMAGE_TEST_ID));
// receive event and expect loading not to be rendered
act(() => sendLoadingEvent({ visible: false }));
await waitFor(() => {
getByTestIdAndThrow(getByTestId, LOADING_TEST_ID);
});
});
it('doesnt have onCancel and doesnt hide when pressed', async () => {
const { getByTestId } = render(<Render />);
getByTestIdAndThrow(getByTestId, LOADING_TEST_ID);
act(() => sendLoadingEvent({ visible: true }));
expect(() => getByTestId(LOADING_TEST_ID));
fireEvent.press(getByTestId(LOADING_BUTTON_TEST_ID));
await waitFor(() => {
expect(() => getByTestId(LOADING_TEST_ID));
});
});
it('has onCancel and hides when pressed', async () => {
const mockFn = jest.fn();
const { getByTestId } = render(<Render />);
getByTestIdAndThrow(getByTestId, LOADING_TEST_ID);
act(() => sendLoadingEvent({ visible: true, onCancel: mockFn }));
await waitFor(() => {
expect(() => getByTestId(LOADING_TEST_ID));
});
fireEvent.press(getByTestId(LOADING_BUTTON_TEST_ID));
await waitFor(() => {
getByTestIdAndThrow(getByTestId, LOADING_TEST_ID);
});
expect(mockFn).toHaveBeenCalled();
});
it('asserts onCancel return', async () => {
const mockFn = jest.fn();
const mockFn2 = jest.fn(() => 'test');
const { getByTestId } = render(<Render />);
getByTestIdAndThrow(getByTestId, LOADING_TEST_ID);
act(() => sendLoadingEvent({ visible: true, onCancel: mockFn }));
await waitFor(() => {
expect(() => getByTestId(LOADING_TEST_ID));
});
act(() => sendLoadingEvent({ visible: true, onCancel: mockFn2 }));
await waitFor(() => {
expect(() => getByTestId(LOADING_TEST_ID));
});
fireEvent.press(getByTestId(LOADING_BUTTON_TEST_ID));
await waitFor(() => {
getByTestIdAndThrow(getByTestId, LOADING_TEST_ID);
});
expect(mockFn).not.toHaveBeenCalled();
expect(mockFn2).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,128 @@
import React, { useEffect, useState } from 'react';
import { StyleSheet, View, PixelRatio, TouchableWithoutFeedback } from 'react-native';
import Animated, {
cancelAnimation,
Extrapolate,
interpolate,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming
} from 'react-native-reanimated';
import { useTheme } from '../../theme';
import EventEmitter from '../../lib/methods/helpers/events';
const LOADING_EVENT = 'LOADING_EVENT';
export const LOADING_TEST_ID = 'loading';
export const LOADING_BUTTON_TEST_ID = 'loading-button';
export const LOADING_IMAGE_TEST_ID = 'loading-image';
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
image: {
width: PixelRatio.get() * 40,
height: PixelRatio.get() * 40,
resizeMode: 'contain'
}
});
interface ILoadingEvent {
visible: boolean;
onCancel?: null | Function;
}
export const sendLoadingEvent = ({ visible, onCancel }: ILoadingEvent): void =>
EventEmitter.emit(LOADING_EVENT, { visible, onCancel });
const Loading = (): React.ReactElement | null => {
const [visible, setVisible] = useState(false);
const [onCancel, setOnCancel] = useState<null | Function>(null);
const opacity = useSharedValue(0);
const scale = useSharedValue(1);
const { colors } = useTheme();
const onEventReceived = ({ visible: _visible, onCancel: _onCancel = null }: ILoadingEvent) => {
if (_visible) {
// if it's already visible, ignore it
if (!visible) {
setVisible(_visible);
opacity.value = 0;
scale.value = 1;
opacity.value = withTiming(1, {
// 300ms doens't work on expensive navigation animations, like jump to message
duration: 500
});
scale.value = withRepeat(withSequence(withTiming(0, { duration: 1000 }), withTiming(1, { duration: 1000 })), -1);
}
// allows to override the onCancel function
if (_onCancel) {
setOnCancel(() => () => _onCancel());
}
} else {
setVisible(false);
reset();
}
};
useEffect(() => {
const listener = EventEmitter.addEventListener(LOADING_EVENT, onEventReceived);
return () => EventEmitter.removeListener(LOADING_EVENT, listener);
}, [visible]);
const reset = () => {
cancelAnimation(scale);
cancelAnimation(opacity);
setVisible(false);
setOnCancel(null);
};
const onCancelHandler = () => {
if (!onCancel) {
return;
}
onCancel();
setVisible(false);
reset();
};
const animatedOpacity = useAnimatedStyle(() => ({
opacity: interpolate(opacity.value, [0, 1], [0, colors.backdropOpacity], Extrapolate.CLAMP)
}));
const animatedScale = useAnimatedStyle(() => ({ transform: [{ scale: interpolate(scale.value, [0, 0.5, 1], [1, 1.1, 1]) }] }));
if (!visible) {
return null;
}
return (
<View style={StyleSheet.absoluteFill} testID={LOADING_TEST_ID}>
<TouchableWithoutFeedback onPress={() => onCancelHandler()} testID={LOADING_BUTTON_TEST_ID}>
<View style={styles.container}>
<Animated.View
style={[
{
...StyleSheet.absoluteFillObject,
backgroundColor: colors.backdropColor
},
animatedOpacity
]}
/>
<Animated.Image
source={require('../../static/images/logo.png')}
style={[styles.image, animatedScale]}
testID={LOADING_IMAGE_TEST_ID}
/>
</View>
</TouchableWithoutFeedback>
</View>
);
};
export default Loading;

View File

@ -14,6 +14,7 @@ import { ActionSheetProvider } from './containers/ActionSheet';
import InAppNotification from './containers/InAppNotification'; import InAppNotification from './containers/InAppNotification';
import Toast from './containers/Toast'; import Toast from './containers/Toast';
import TwoFactor from './containers/TwoFactor'; import TwoFactor from './containers/TwoFactor';
import Loading from './containers/Loading';
import { ICommand } from './definitions/ICommand'; import { ICommand } from './definitions/ICommand';
import { IThemePreference } from './definitions/ITheme'; import { IThemePreference } from './definitions/ITheme';
import { DimensionsContext } from './dimensions'; import { DimensionsContext } from './dimensions';
@ -241,6 +242,7 @@ export default class Root extends React.Component<{}, IState> {
<ChangePasscodeView /> <ChangePasscodeView />
<InAppNotification /> <InAppNotification />
<Toast /> <Toast />
<Loading />
</ActionSheetProvider> </ActionSheetProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
</DimensionsContext.Provider> </DimensionsContext.Provider>

View File

@ -10,6 +10,7 @@ type TEventEmitterEmmitArgs =
| { invalid: boolean } | { invalid: boolean }
| { force: boolean } | { force: boolean }
| { hasBiometry: boolean } | { hasBiometry: boolean }
| { visible: boolean; onCancel?: null | Function }
| { event: string | ICommand } | { event: string | ICommand }
| { cancel: () => void } | { cancel: () => void }
| { submit: (param: string) => void } | { submit: (param: string) => void }

View File

@ -25,6 +25,7 @@ import AuthLoadingView from './views/AuthLoadingView';
import { DimensionsContext } from './dimensions'; import { DimensionsContext } from './dimensions';
import { ShareInsideStackParamList, ShareOutsideStackParamList, ShareAppStackParamList } from './definitions/navigationTypes'; import { ShareInsideStackParamList, ShareOutsideStackParamList, ShareAppStackParamList } from './definitions/navigationTypes';
import { colors, CURRENT_SERVER } from './lib/constants'; import { colors, CURRENT_SERVER } from './lib/constants';
import Loading from './containers/Loading';
initStore(store); initStore(store);
@ -131,6 +132,7 @@ const Root = (): React.ReactElement => {
}} }}
> >
<App root={root} /> <App root={root} />
<Loading />
</NavigationContainer> </NavigationContainer>
<ScreenLockedView /> <ScreenLockedView />
</DimensionsContext.Provider> </DimensionsContext.Provider>

View File

@ -15,7 +15,7 @@ import StatusBar from '../containers/StatusBar';
import { themes } from '../lib/constants'; import { themes } from '../lib/constants';
import { TSupportedThemes, withTheme } from '../theme'; import { TSupportedThemes, withTheme } from '../theme';
import SafeAreaView from '../containers/SafeAreaView'; import SafeAreaView from '../containers/SafeAreaView';
import Loading from '../containers/Loading'; import { sendLoadingEvent } from '../containers/Loading';
import { animateNextTransition } from '../lib/methods/helpers/layoutAnimation'; import { animateNextTransition } from '../lib/methods/helpers/layoutAnimation';
import { goRoom } from '../lib/methods/helpers/goRoom'; import { goRoom } from '../lib/methods/helpers/goRoom';
import { showErrorAlert } from '../lib/methods/helpers/info'; import { showErrorAlert } from '../lib/methods/helpers/info';
@ -28,7 +28,6 @@ interface IAddExistingChannelViewState {
search: TSubscriptionModel[]; search: TSubscriptionModel[];
channels: TSubscriptionModel[]; channels: TSubscriptionModel[];
selected: string[]; selected: string[];
loading: boolean;
} }
interface IAddExistingChannelViewProps { interface IAddExistingChannelViewProps {
@ -51,8 +50,7 @@ class AddExistingChannelView extends React.Component<IAddExistingChannelViewProp
this.state = { this.state = {
search: [], search: [],
channels: [], channels: [],
selected: [], selected: []
loading: false
}; };
this.setHeader(); this.setHeader();
} }
@ -130,12 +128,12 @@ class AddExistingChannelView extends React.Component<IAddExistingChannelViewProp
const { selected } = this.state; const { selected } = this.state;
const { isMasterDetail } = this.props; const { isMasterDetail } = this.props;
this.setState({ loading: true }); sendLoadingEvent({ visible: true });
try { try {
logEvent(events.CT_ADD_ROOM_TO_TEAM); logEvent(events.CT_ADD_ROOM_TO_TEAM);
const result = await Services.addRoomsToTeam({ rooms: selected, teamId: this.teamId }); const result = await Services.addRoomsToTeam({ rooms: selected, teamId: this.teamId });
if (result.success) { if (result.success) {
this.setState({ loading: false }); sendLoadingEvent({ visible: false });
// @ts-ignore // @ts-ignore
// TODO: Verify goRoom interface for return of call // TODO: Verify goRoom interface for return of call
goRoom({ item: result, isMasterDetail }); goRoom({ item: result, isMasterDetail });
@ -143,7 +141,7 @@ class AddExistingChannelView extends React.Component<IAddExistingChannelViewProp
} catch (e: any) { } catch (e: any) {
logEvent(events.CT_ADD_ROOM_TO_TEAM_F); logEvent(events.CT_ADD_ROOM_TO_TEAM_F);
showErrorAlert(I18n.t(e.data.error), I18n.t('Add_Existing_Channel'), () => {}); showErrorAlert(I18n.t(e.data.error), I18n.t('Add_Existing_Channel'), () => {});
this.setState({ loading: false }); sendLoadingEvent({ visible: false });
} }
}; };
@ -209,13 +207,10 @@ class AddExistingChannelView extends React.Component<IAddExistingChannelViewProp
}; };
render() { render() {
const { loading } = this.state;
return ( return (
<SafeAreaView testID='add-existing-channel-view'> <SafeAreaView testID='add-existing-channel-view'>
<StatusBar /> <StatusBar />
{this.renderList()} {this.renderList()}
<Loading visible={loading} />
</SafeAreaView> </SafeAreaView>
); );
} }

View File

@ -5,7 +5,7 @@ import { dequal } from 'dequal';
import * as List from '../containers/List'; import * as List from '../containers/List';
import { TextInput } from '../containers/TextInput'; import { TextInput } from '../containers/TextInput';
import Loading from '../containers/Loading'; import { sendLoadingEvent } from '../containers/Loading';
import { createChannelRequest } from '../actions/createChannel'; import { createChannelRequest } from '../actions/createChannel';
import { removeUser } from '../actions/selectedUsers'; import { removeUser } from '../actions/selectedUsers';
import KeyboardView from '../containers/KeyboardView'; import KeyboardView from '../containers/KeyboardView';
@ -169,13 +169,16 @@ class CreateChannelView extends React.Component<ICreateChannelViewProps, ICreate
} }
componentDidUpdate(prevProps: ICreateChannelViewProps) { componentDidUpdate(prevProps: ICreateChannelViewProps) {
const { createPublicChannelPermission, createPrivateChannelPermission } = this.props; const { createPublicChannelPermission, createPrivateChannelPermission, isFetching } = this.props;
if ( if (
!dequal(createPublicChannelPermission, prevProps.createPublicChannelPermission) || !dequal(createPublicChannelPermission, prevProps.createPublicChannelPermission) ||
!dequal(createPrivateChannelPermission, prevProps.createPrivateChannelPermission) !dequal(createPrivateChannelPermission, prevProps.createPrivateChannelPermission)
) { ) {
this.handleHasPermission(); this.handleHasPermission();
} }
if (isFetching !== prevProps.isFetching) {
sendLoadingEvent({ visible: isFetching });
}
} }
setHeader = () => { setHeader = () => {
@ -368,7 +371,7 @@ class CreateChannelView extends React.Component<ICreateChannelViewProps, ICreate
render() { render() {
const { channelName, isTeam } = this.state; const { channelName, isTeam } = this.state;
const { users, isFetching, theme } = this.props; const { users, theme } = this.props;
const userCount = users.length; const userCount = users.length;
return ( return (
@ -409,7 +412,6 @@ class CreateChannelView extends React.Component<ICreateChannelViewProps, ICreate
</Text> </Text>
</View> </View>
{this.renderInvitedList()} {this.renderInvitedList()}
<Loading visible={isFetching} />
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
</KeyboardView> </KeyboardView>

View File

@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import { ScrollView, Switch, Text } from 'react-native'; import { ScrollView, Switch, Text } from 'react-native';
import { StackNavigationOptions } from '@react-navigation/stack'; import { StackNavigationOptions } from '@react-navigation/stack';
import Loading from '../../containers/Loading'; import { sendLoadingEvent } from '../../containers/Loading';
import KeyboardView from '../../containers/KeyboardView'; import KeyboardView from '../../containers/KeyboardView';
import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps'; import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps';
import I18n from '../../i18n'; import I18n from '../../i18n';
@ -52,8 +52,9 @@ class CreateChannelView extends React.Component<ICreateChannelViewProps, ICreate
this.setHeader(); this.setHeader();
} }
if (!loading && loading !== prevProps.loading) { if (loading !== prevProps.loading) {
setTimeout(() => { sendLoadingEvent({ visible: loading });
if (!loading) {
if (failure) { if (failure) {
const msg = error.reason || I18n.t('There_was_an_error_while_action', { action: I18n.t('creating_discussion') }); const msg = error.reason || I18n.t('There_was_an_error_while_action', { action: I18n.t('creating_discussion') });
showErrorAlert(msg); showErrorAlert(msg);
@ -72,7 +73,7 @@ class CreateChannelView extends React.Component<ICreateChannelViewProps, ICreate
}; };
goRoom({ item, isMasterDetail }); goRoom({ item, isMasterDetail });
} }
}, 300); }
} }
} }
@ -146,7 +147,7 @@ class CreateChannelView extends React.Component<ICreateChannelViewProps, ICreate
render() { render() {
const { name, users, encrypted } = this.state; const { name, users, encrypted } = this.state;
const { server, user, loading, blockUnauthenticatedAccess, theme, serverVersion } = this.props; const { server, user, blockUnauthenticatedAccess, theme, serverVersion } = this.props;
return ( return (
<KeyboardView <KeyboardView
style={{ backgroundColor: themes[theme].auxiliaryBackground }} style={{ backgroundColor: themes[theme].auxiliaryBackground }}
@ -189,7 +190,6 @@ class CreateChannelView extends React.Component<ICreateChannelViewProps, ICreate
<Switch value={encrypted} onValueChange={this.onEncryptedChange} trackColor={SWITCH_TRACK_COLOR} /> <Switch value={encrypted} onValueChange={this.onEncryptedChange} trackColor={SWITCH_TRACK_COLOR} />
</> </>
) : null} ) : null}
<Loading visible={loading} />
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
</KeyboardView> </KeyboardView>

View File

@ -11,7 +11,7 @@ import { Subscription } from 'rxjs';
import { deleteRoom } from '../../actions/room'; import { deleteRoom } from '../../actions/room';
import { themes } from '../../lib/constants'; import { themes } from '../../lib/constants';
import Avatar from '../../containers/Avatar'; import Avatar from '../../containers/Avatar';
import Loading from '../../containers/Loading'; import { sendLoadingEvent } from '../../containers/Loading';
import SafeAreaView from '../../containers/SafeAreaView'; import SafeAreaView from '../../containers/SafeAreaView';
import StatusBar from '../../containers/StatusBar'; import StatusBar from '../../containers/StatusBar';
import { FormTextInput } from '../../containers/TextInput'; import { FormTextInput } from '../../containers/TextInput';
@ -62,7 +62,6 @@ interface IRoomInfoEditViewState {
announcement?: string; announcement?: string;
joinCode: string; joinCode: string;
nameError: any; nameError: any;
saving: boolean;
t: boolean; t: boolean;
ro: boolean; ro: boolean;
reactWhenReadOnly?: boolean; reactWhenReadOnly?: boolean;
@ -111,7 +110,6 @@ class RoomInfoEditView extends React.Component<IRoomInfoEditViewProps, IRoomInfo
announcement: '', announcement: '',
joinCode: '', joinCode: '',
nameError: {}, nameError: {},
saving: false,
t: false, t: false,
ro: false, ro: false,
reactWhenReadOnly: false, reactWhenReadOnly: false,
@ -269,7 +267,7 @@ class RoomInfoEditView extends React.Component<IRoomInfoEditViewProps, IRoomInfo
avatar avatar
} = this.state; } = this.state;
this.setState({ saving: true }); sendLoadingEvent({ visible: true });
let error = false; let error = false;
if (!this.formIsChanged()) { if (!this.formIsChanged()) {
@ -339,7 +337,7 @@ class RoomInfoEditView extends React.Component<IRoomInfoEditViewProps, IRoomInfo
log(e); log(e);
} }
await this.setState({ saving: false }); sendLoadingEvent({ visible: false });
setTimeout(() => { setTimeout(() => {
if (error) { if (error) {
logEvent(events.RI_EDIT_SAVE_F); logEvent(events.RI_EDIT_SAVE_F);
@ -548,7 +546,6 @@ class RoomInfoEditView extends React.Component<IRoomInfoEditViewProps, IRoomInfo
reactWhenReadOnly, reactWhenReadOnly,
room, room,
joinCode, joinCode,
saving,
permissions, permissions,
archived, archived,
enableSysMes, enableSysMes,
@ -811,7 +808,6 @@ class RoomInfoEditView extends React.Component<IRoomInfoEditViewProps, IRoomInfo
{I18n.t('DELETE')} {I18n.t('DELETE')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<Loading visible={saving} />
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
</KeyboardView> </KeyboardView>

View File

@ -62,6 +62,7 @@ class ListContainer extends React.Component<IListContainerProps, IListContainerS
private mounted = false; private mounted = false;
private animated = false; private animated = false;
private jumping = false; private jumping = false;
private cancelJump = false;
private y = new Value(0); private y = new Value(0);
private onScroll = onScroll({ y: this.y }); private onScroll = onScroll({ y: this.y });
private unsubscribeFocus: () => void; private unsubscribeFocus: () => void;
@ -138,6 +139,7 @@ class ListContainer extends React.Component<IListContainerProps, IListContainerS
console.countReset(`${this.constructor.name}.render calls`); console.countReset(`${this.constructor.name}.render calls`);
} }
// clears previous highlighted message timeout, if exists
clearHighlightedMessageTimeout = () => { clearHighlightedMessageTimeout = () => {
if (this.highlightedMessageTimeout) { if (this.highlightedMessageTimeout) {
clearTimeout(this.highlightedMessageTimeout); clearTimeout(this.highlightedMessageTimeout);
@ -276,40 +278,60 @@ class ListContainer extends React.Component<IListContainerProps, IListContainerS
jumpToMessage = (messageId: string) => jumpToMessage = (messageId: string) =>
new Promise<void>(async resolve => { new Promise<void>(async resolve => {
this.jumping = true;
const { messages } = this.state; const { messages } = this.state;
const { listRef } = this.props; const { listRef } = this.props;
// if jump to message was cancelled, reset variables and stop
if (this.cancelJump) {
this.resetJumpToMessage();
return resolve();
}
this.jumping = true;
// look for the message on the state
const index = messages.findIndex(item => item.id === messageId); const index = messages.findIndex(item => item.id === messageId);
// if found message, scroll to it
if (index > -1) { if (index > -1) {
listRef.current?.scrollToIndex({ index, viewPosition: 0.5, viewOffset: 100 }); listRef.current?.scrollToIndex({ index, viewPosition: 0.5, viewOffset: 100 });
// wait for scroll animation to finish
await new Promise(res => setTimeout(res, 300)); await new Promise(res => setTimeout(res, 300));
// if message is not visible
if (!this.viewableItems?.map(vi => vi.key).includes(messageId)) { if (!this.viewableItems?.map(vi => vi.key).includes(messageId)) {
if (!this.jumping) {
return resolve();
}
await setTimeout(() => resolve(this.jumpToMessage(messageId)), 300); await setTimeout(() => resolve(this.jumpToMessage(messageId)), 300);
return; return;
} }
// if message is visible, highlight it
this.setState({ highlightedMessage: messageId }); this.setState({ highlightedMessage: messageId });
this.clearHighlightedMessageTimeout(); this.clearHighlightedMessageTimeout();
// clears highlighted message after some time
this.highlightedMessageTimeout = setTimeout(() => { this.highlightedMessageTimeout = setTimeout(() => {
this.setState({ highlightedMessage: null }); this.setState({ highlightedMessage: null });
}, 10000); }, 5000);
await setTimeout(() => resolve(), 300); this.resetJumpToMessage();
resolve();
} else { } else {
listRef.current?.scrollToIndex({ index: messages.length - 1, animated: false }); // if message not found, wait for scroll to top and then jump to message
if (!this.jumping) { listRef.current?.scrollToIndex({ index: messages.length - 1, animated: true });
return resolve();
}
await setTimeout(() => resolve(this.jumpToMessage(messageId)), 300); await setTimeout(() => resolve(this.jumpToMessage(messageId)), 300);
} }
}); });
// this.jumping is checked in between operations to make sure we're not stuck resetJumpToMessage = () => {
cancelJumpToMessage = () => { this.cancelJump = false;
this.jumping = false; this.jumping = false;
}; };
cancelJumpToMessage = () => {
if (this.jumping) {
this.cancelJump = true;
return;
}
this.resetJumpToMessage();
};
jumpToBottom = () => { jumpToBottom = () => {
const { listRef } = this.props; const { listRef } = this.props;
listRef.current?.scrollToOffset({ offset: -100 }); listRef.current?.scrollToOffset({ offset: -100 });

View File

@ -42,7 +42,7 @@ import Navigation from '../../lib/navigation/appNavigation';
import SafeAreaView from '../../containers/SafeAreaView'; import SafeAreaView from '../../containers/SafeAreaView';
import { withDimensions } from '../../dimensions'; import { withDimensions } from '../../dimensions';
import { takeInquiry, takeResume } from '../../ee/omnichannel/lib'; import { takeInquiry, takeResume } from '../../ee/omnichannel/lib';
import Loading from '../../containers/Loading'; import { sendLoadingEvent } from '../../containers/Loading';
import { goRoom, TGoRoomItem } from '../../lib/methods/helpers/goRoom'; import { goRoom, TGoRoomItem } from '../../lib/methods/helpers/goRoom';
import getThreadName from '../../lib/methods/getThreadName'; import getThreadName from '../../lib/methods/getThreadName';
import getRoomInfo from '../../lib/methods/getRoomInfo'; import getRoomInfo from '../../lib/methods/getRoomInfo';
@ -115,7 +115,6 @@ const stateAttrsUpdate = [
'reacting', 'reacting',
'readOnly', 'readOnly',
'member', 'member',
'showingBlockingLoader',
'canForwardGuest', 'canForwardGuest',
'canReturnQueue', 'canReturnQueue',
'canViewCannedResponse' 'canViewCannedResponse'
@ -198,7 +197,6 @@ interface IRoomViewState {
selectedMessage?: TAnyMessageModel; selectedMessage?: TAnyMessageModel;
canAutoTranslate: boolean; canAutoTranslate: boolean;
loading: boolean; loading: boolean;
showingBlockingLoader: boolean;
editing: boolean; editing: boolean;
replying: boolean; replying: boolean;
replyWithMention: boolean; replyWithMention: boolean;
@ -273,7 +271,6 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
selectedMessage, selectedMessage,
canAutoTranslate: false, canAutoTranslate: false,
loading: true, loading: true,
showingBlockingLoader: false,
editing: false, editing: false,
replying: !!selectedMessage, replying: !!selectedMessage,
replyWithMention: false, replyWithMention: false,
@ -940,25 +937,23 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
return; return;
} }
try { try {
this.setState({ showingBlockingLoader: true });
const parsedUrl = parse(messageUrl, true); const parsedUrl = parse(messageUrl, true);
const messageId = parsedUrl.query.msg; const messageId = parsedUrl.query.msg;
if (messageId) { if (messageId) {
await this.jumpToMessage(messageId); await this.jumpToMessage(messageId);
} }
this.setState({ showingBlockingLoader: false });
} catch (e) { } catch (e) {
this.setState({ showingBlockingLoader: false });
log(e); log(e);
} }
}; };
jumpToMessage = async (messageId: string) => { jumpToMessage = async (messageId: string) => {
try { try {
this.setState({ showingBlockingLoader: true }); sendLoadingEvent({ visible: true, onCancel: this.cancelJumpToMessage });
const message = await RoomServices.getMessageInfo(messageId); const message = await RoomServices.getMessageInfo(messageId);
if (!message) { if (!message) {
this.cancelJumpToMessage();
return; return;
} }
@ -977,15 +972,19 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
await loadSurroundingMessages({ messageId, rid: this.rid }); await loadSurroundingMessages({ messageId, rid: this.rid });
} }
await Promise.race([this.list.current?.jumpToMessage(message.id), new Promise(res => setTimeout(res, 5000))]); await Promise.race([this.list.current?.jumpToMessage(message.id), new Promise(res => setTimeout(res, 5000))]);
this.list.current?.cancelJumpToMessage(); this.cancelJumpToMessage();
} }
} catch (e) { } catch (e) {
log(e); log(e);
} finally { this.cancelJumpToMessage();
this.setState({ showingBlockingLoader: false });
} }
}; };
cancelJumpToMessage = () => {
this.list.current?.cancelJumpToMessage();
sendLoadingEvent({ visible: false });
};
replyBroadcast = (message: IMessage) => { replyBroadcast = (message: IMessage) => {
const { dispatch } = this.props; const { dispatch } = this.props;
dispatch(replyBroadcast(message)); dispatch(replyBroadcast(message));
@ -1137,10 +1136,12 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
name = item.tmsg ?? ''; name = item.tmsg ?? '';
jumpToMessageId = item.id; jumpToMessageId = item.id;
} }
sendLoadingEvent({ visible: true, onCancel: this.cancelJumpToMessage });
if (!name) { if (!name) {
const result = await this.getThreadName(item.tmid, jumpToMessageId); const result = await this.getThreadName(item.tmid, jumpToMessageId);
// test if there isn't a thread // test if there isn't a thread
if (!result) { if (!result) {
sendLoadingEvent({ visible: false });
return; return;
} }
name = result; name = result;
@ -1484,7 +1485,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
render() { render() {
console.count(`${this.constructor.name}.render calls`); console.count(`${this.constructor.name}.render calls`);
const { room, selectedMessage, loading, reacting, showingBlockingLoader } = this.state; const { room, selectedMessage, loading, reacting } = this.state;
const { user, baseUrl, theme, navigation, Hide_System_Messages, width, height, serverVersion } = this.props; const { user, baseUrl, theme, navigation, Hide_System_Messages, width, height, serverVersion } = this.props;
const { rid, t } = room; const { rid, t } = room;
let sysMes; let sysMes;
@ -1528,7 +1529,6 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
/> />
<UploadProgress rid={rid} user={user} baseUrl={baseUrl} width={width} /> <UploadProgress rid={rid} user={user} baseUrl={baseUrl} width={width} />
<JoinCode ref={this.joinCode} onJoin={this.onJoin} rid={rid} t={t} theme={theme} /> <JoinCode ref={this.joinCode} onJoin={this.onJoin} rid={rid} t={t} theme={theme} />
<Loading visible={showingBlockingLoader} />
</SafeAreaView> </SafeAreaView>
); );
} }

View File

@ -9,7 +9,7 @@ import { addUser, removeUser, reset } from '../actions/selectedUsers';
import { themes } from '../lib/constants'; import { themes } from '../lib/constants';
import * as HeaderButton from '../containers/HeaderButton'; import * as HeaderButton from '../containers/HeaderButton';
import * as List from '../containers/List'; import * as List from '../containers/List';
import Loading from '../containers/Loading'; import { sendLoadingEvent } from '../containers/Loading';
import SafeAreaView from '../containers/SafeAreaView'; import SafeAreaView from '../containers/SafeAreaView';
import SearchBox from '../containers/SearchBox'; import SearchBox from '../containers/SearchBox';
import StatusBar from '../containers/StatusBar'; import StatusBar from '../containers/StatusBar';
@ -65,12 +65,15 @@ class SelectedUsersView extends React.Component<ISelectedUsersViewProps, ISelect
} }
componentDidUpdate(prevProps: ISelectedUsersViewProps) { componentDidUpdate(prevProps: ISelectedUsersViewProps) {
const { users, loading } = this.props;
if (this.isGroupChat()) { if (this.isGroupChat()) {
const { users } = this.props;
if (prevProps.users.length !== users.length) { if (prevProps.users.length !== users.length) {
this.setHeader(users.length > 0); this.setHeader(users.length > 0);
} }
} }
if (loading !== prevProps.loading) {
sendLoadingEvent({ visible: loading });
}
} }
componentWillUnmount() { componentWillUnmount() {
@ -273,16 +276,14 @@ class SelectedUsersView extends React.Component<ISelectedUsersViewProps, ISelect
); );
}; };
render = () => { render() {
const { loading } = this.props;
return ( return (
<SafeAreaView testID='select-users-view'> <SafeAreaView testID='select-users-view'>
<StatusBar /> <StatusBar />
{this.renderList()} {this.renderList()}
<Loading visible={loading} />
</SafeAreaView> </SafeAreaView>
); );
}; }
} }
const mapStateToProps = (state: IApplicationState) => ({ const mapStateToProps = (state: IApplicationState) => ({

View File

@ -9,7 +9,7 @@ import { Q } from '@nozbe/watermelondb';
import { InsideStackParamList } from '../../stacks/types'; import { InsideStackParamList } from '../../stacks/types';
import { themes } from '../../lib/constants'; import { themes } from '../../lib/constants';
import I18n from '../../i18n'; import I18n from '../../i18n';
import Loading from '../../containers/Loading'; import { sendLoadingEvent } from '../../containers/Loading';
import * as HeaderButton from '../../containers/HeaderButton'; import * as HeaderButton from '../../containers/HeaderButton';
import { TSupportedThemes, withTheme } from '../../theme'; import { TSupportedThemes, withTheme } from '../../theme';
import { FormTextInput } from '../../containers/TextInput'; import { FormTextInput } from '../../containers/TextInput';
@ -207,6 +207,7 @@ class ShareView extends Component<IShareViewProps, IShareViewState> {
// if it's share extension this should show loading // if it's share extension this should show loading
if (this.isShareExtension) { if (this.isShareExtension) {
this.setState({ loading: true }); this.setState({ loading: true });
sendLoadingEvent({ visible: true });
// if it's not share extension this can close // if it's not share extension this can close
} else { } else {
@ -248,6 +249,7 @@ class ShareView extends Component<IShareViewProps, IShareViewState> {
// if it's share extension this should close // if it's share extension this should close
if (this.isShareExtension) { if (this.isShareExtension) {
sendLoadingEvent({ visible: false });
ShareExtension.close(); ShareExtension.close();
} }
}; };
@ -346,7 +348,7 @@ class ShareView extends Component<IShareViewProps, IShareViewState> {
render() { render() {
console.count(`${this.constructor.name}.render calls`); console.count(`${this.constructor.name}.render calls`);
const { readOnly, room, loading } = this.state; const { readOnly, room } = this.state;
const { theme } = this.props; const { theme } = this.props;
if (readOnly || isBlocked(room)) { if (readOnly || isBlocked(room)) {
return ( return (
@ -361,7 +363,6 @@ class ShareView extends Component<IShareViewProps, IShareViewState> {
<SafeAreaView style={{ backgroundColor: themes[theme].backgroundColor }}> <SafeAreaView style={{ backgroundColor: themes[theme].backgroundColor }}>
<StatusBar barStyle='light-content' backgroundColor={themes[theme].previewBackground} /> <StatusBar barStyle='light-content' backgroundColor={themes[theme].previewBackground} />
{this.renderContent()} {this.renderContent()}
<Loading visible={loading} />
</SafeAreaView> </SafeAreaView>
); );
} }

View File

@ -6,7 +6,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { setUser } from '../../actions/login'; import { setUser } from '../../actions/login';
import * as HeaderButton from '../../containers/HeaderButton'; import * as HeaderButton from '../../containers/HeaderButton';
import * as List from '../../containers/List'; import * as List from '../../containers/List';
import Loading from '../../containers/Loading'; import { sendLoadingEvent } from '../../containers/Loading';
import SafeAreaView from '../../containers/SafeAreaView'; import SafeAreaView from '../../containers/SafeAreaView';
import StatusIcon from '../../containers/Status/Status'; import StatusIcon from '../../containers/Status/Status';
import { FormTextInput } from '../../containers/TextInput'; import { FormTextInput } from '../../containers/TextInput';
@ -91,7 +91,6 @@ const StatusView = (): React.ReactElement => {
); );
const [statusText, setStatusText] = useState(user.statusText || ''); const [statusText, setStatusText] = useState(user.statusText || '');
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState(user.status); const [status, setStatus] = useState(user.status);
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -120,7 +119,7 @@ const StatusView = (): React.ReactElement => {
}, [statusText, status]); }, [statusText, status]);
const setCustomStatus = async (status: TUserStatus, statusText: string) => { const setCustomStatus = async (status: TUserStatus, statusText: string) => {
setLoading(true); sendLoadingEvent({ visible: true });
try { try {
await Services.setUserStatus(status, statusText); await Services.setUserStatus(status, statusText);
dispatch(setUser({ statusText, status })); dispatch(setUser({ statusText, status }));
@ -135,7 +134,7 @@ const StatusView = (): React.ReactElement => {
showErrorAlert(messageError); showErrorAlert(messageError);
log(e); log(e);
} }
setLoading(false); sendLoadingEvent({ visible: false });
}; };
const statusType = Accounts_AllowInvisibleStatusOption ? STATUS : STATUS.filter(s => s.id !== 'offline'); const statusType = Accounts_AllowInvisibleStatusOption ? STATUS : STATUS.filter(s => s.id !== 'offline');
@ -163,7 +162,6 @@ const StatusView = (): React.ReactElement => {
ListFooterComponent={List.Separator} ListFooterComponent={List.Separator}
ItemSeparatorComponent={List.Separator} ItemSeparatorComponent={List.Separator}
/> />
<Loading visible={loading} />
</SafeAreaView> </SafeAreaView>
); );
}; };

View File

@ -49,10 +49,10 @@ async function waitForLoading() {
await sleep(10000); await sleep(10000);
return; // FIXME: Loading indicator doesn't animate properly on android return; // FIXME: Loading indicator doesn't animate properly on android
} }
await waitFor(element(by.id('loading'))) await waitFor(element(by.id('loading-image')))
.toBeVisible() .toBeVisible()
.withTimeout(5000); .withTimeout(5000);
await waitFor(element(by.id('loading'))) await waitFor(element(by.id('loading-image')))
.toBeNotVisible() .toBeNotVisible()
.withTimeout(10000); .withTimeout(10000);
} }
@ -67,9 +67,10 @@ describe('Room', () => {
it('should jump to an old message and load its surroundings', async () => { it('should jump to an old message and load its surroundings', async () => {
await navigateToRoom('jumping'); await navigateToRoom('jumping');
await waitFor(element(by[textMatcher]('300'))) await waitFor(element(by[textMatcher]('295')))
.toExist() .toExist()
.withTimeout(5000); .withTimeout(5000);
await sleep(2000);
await element(by[textMatcher]('1')).atIndex(0).tap(); await element(by[textMatcher]('1')).atIndex(0).tap();
await waitForLoading(); await waitForLoading();
await waitFor(element(by[textMatcher]('1')).atIndex(0)) await waitFor(element(by[textMatcher]('1')).atIndex(0))
@ -83,7 +84,7 @@ describe('Room', () => {
.toExist() .toExist()
.withTimeout(15000); .withTimeout(15000);
await element(by.id('nav-jump-to-bottom')).tap(); await element(by.id('nav-jump-to-bottom')).tap();
await waitFor(element(by[textMatcher]('Quote first message'))) await waitFor(element(by[textMatcher]("Go to jumping-thread's thread")))
.toExist() .toExist()
.withTimeout(15000); .withTimeout(15000);
await clearCache(); await clearCache();
@ -164,7 +165,7 @@ describe('Room', () => {
await waitFor(element(by[textMatcher]('50'))) await waitFor(element(by[textMatcher]('50')))
.toExist() .toExist()
.withTimeout(5000); .withTimeout(5000);
await element(by.id('room-view-messages')).atIndex(0).swipe('up', 'slow', 0.5); await element(by.id('room-view-messages')).atIndex(0).swipe('up', 'slow', 0.4);
await waitFor(element(by[textMatcher]('Load Newer'))) await waitFor(element(by[textMatcher]('Load Newer')))
.toExist() .toExist()
.withTimeout(5000); .withTimeout(5000);
@ -197,8 +198,9 @@ const expectThreadMessages = async message => {
await waitFor(element(by.id('room-view-title-thread 1'))) await waitFor(element(by.id('room-view-title-thread 1')))
.toExist() .toExist()
.withTimeout(5000); .withTimeout(5000);
await waitForLoading(); await waitFor(element(by[textMatcher](message)).atIndex(0))
await expect(element(by[textMatcher](message)).atIndex(0)).toExist(); .toExist()
.withTimeout(10000);
await element(by[textMatcher](message)).atIndex(0).tap(); await element(by[textMatcher](message)).atIndex(0).tap();
}; };

View File

@ -176,7 +176,7 @@
"@typescript-eslint/eslint-plugin": "^4.28.3", "@typescript-eslint/eslint-plugin": "^4.28.3",
"@typescript-eslint/parser": "^4.28.5", "@typescript-eslint/parser": "^4.28.5",
"axios": "0.27.2", "axios": "0.27.2",
"babel-jest": "^27.0.6", "babel-jest": "^28.1.3",
"babel-plugin-transform-remove-console": "^6.9.4", "babel-plugin-transform-remove-console": "^6.9.4",
"codecov": "^3.8.3", "codecov": "^3.8.3",
"detox": "19.7.0", "detox": "19.7.0",
@ -190,9 +190,9 @@
"eslint-plugin-react-native": "4.0.0", "eslint-plugin-react-native": "4.0.0",
"husky": "^6.0.0", "husky": "^6.0.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^27.0.6", "jest": "^28.1.3",
"jest-cli": "^27.0.6", "jest-cli": "^28.1.3",
"jest-expo": "^45.0.1", "jest-expo": "^46.0.1",
"metro-react-native-babel-preset": "^0.67.0", "metro-react-native-babel-preset": "^0.67.0",
"mocha": "9.0.1", "mocha": "9.0.1",
"otp.js": "1.2.0", "otp.js": "1.2.0",

View File

@ -0,0 +1,14 @@
diff --git a/node_modules/react-native-reanimated/lib/reanimated2/jestUtils.js b/node_modules/react-native-reanimated/lib/reanimated2/jestUtils.js
index 5ae42ec..273bbc0 100644
--- a/node_modules/react-native-reanimated/lib/reanimated2/jestUtils.js
+++ b/node_modules/react-native-reanimated/lib/reanimated2/jestUtils.js
@@ -145,7 +145,8 @@ export const advanceAnimationByFrame = (count) => {
jest.advanceTimersByTime(frameTime);
};
export const setUpTests = (userConfig = {}) => {
- const expect = require('expect');
+ // https://github.com/software-mansion/react-native-reanimated/issues/3215
+ const { expect } = require('@jest/globals');
require('setimmediate');
frameTime = Math.round(1000 / config.fps);
config = Object.assign(Object.assign({}, config), userConfig);

1528
yarn.lock

File diff suppressed because it is too large Load Diff