chore: Migrate RoomView/List to Hooks (#5207)

This commit is contained in:
Diego Mello 2023-09-21 15:32:47 -03:00 committed by GitHub
parent ed20c855d1
commit 043f48ae54
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 493 additions and 549 deletions

View File

@ -1,49 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FlatListProps, StyleSheet } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
import { isIOS } from '../../../lib/methods/helpers';
import scrollPersistTaps from '../../../lib/methods/helpers/scrollPersistTaps';
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
const styles = StyleSheet.create({
list: {
flex: 1
},
contentContainer: {
paddingTop: 10
}
});
export type TListRef = React.RefObject<FlatList & { getNode: () => FlatList }>;
export interface IListProps extends FlatListProps<any> {
listRef: TListRef;
}
const List = ({ listRef, ...props }: IListProps) => (
<AnimatedFlatList
testID='room-view-messages'
ref={listRef}
keyExtractor={(item: any) => item.id}
contentContainerStyle={styles.contentContainer}
style={styles.list}
inverted={isIOS}
removeClippedSubviews={isIOS}
initialNumToRender={7}
onEndReachedThreshold={0.5}
maxToRenderPerBatch={5}
windowSize={10}
{...props}
{...scrollPersistTaps}
/>
);
List.propTypes = {
listRef: PropTypes.object
};
export default List;

View File

@ -1,75 +0,0 @@
import React, { useState } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, { call, cond, greaterOrEq, useCode } from 'react-native-reanimated';
import { themes } from '../../../lib/constants';
import { CustomIcon } from '../../../containers/CustomIcon';
import { useTheme } from '../../../theme';
import Touch from '../../../containers/Touch';
import { hasNotch } from '../../../lib/methods/helpers';
const SCROLL_LIMIT = 200;
const SEND_TO_CHANNEL_HEIGHT = 40;
const styles = StyleSheet.create({
container: {
position: 'absolute',
right: 15
},
button: {
borderRadius: 25
},
content: {
width: 50,
height: 50,
borderRadius: 25,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center'
}
});
const NavBottomFAB = ({
y,
onPress,
isThread
}: {
y: Animated.Value<number>;
onPress: Function;
isThread: boolean;
}): React.ReactElement | null => {
const { theme } = useTheme();
const [show, setShow] = useState(false);
const handleOnPress = () => onPress();
const toggle = (v: boolean) => setShow(v);
useCode(
() =>
cond(
greaterOrEq(y, SCROLL_LIMIT),
call([y], () => toggle(true)),
call([y], () => toggle(false))
),
[y]
);
if (!show) {
return null;
}
let bottom = hasNotch ? 100 : 60;
if (isThread) {
bottom += SEND_TO_CHANNEL_HEIGHT;
}
return (
<Animated.View style={[styles.container, { bottom }]} testID='nav-jump-to-bottom'>
<Touch onPress={handleOnPress} style={[styles.button, { backgroundColor: themes[theme].backgroundColor }]}>
<View style={[styles.content, { borderColor: themes[theme].borderColor }]}>
<CustomIcon name='chevron-down' color={themes[theme].auxiliaryTintColor} size={36} />
</View>
</Touch>
</Animated.View>
);
};
export default NavBottomFAB;

View File

@ -1,7 +1,7 @@
import React from 'react';
import { ImageBackground, StyleSheet } from 'react-native';
import { useTheme } from '../../theme';
import { useTheme } from '../../../../theme';
const styles = StyleSheet.create({
image: {
@ -11,12 +11,10 @@ const styles = StyleSheet.create({
}
});
const EmptyRoom = React.memo(({ length, mounted, rid }: { length: number; mounted: boolean; rid: string }) => {
export const EmptyRoom = React.memo(({ length, rid }: { length: number; rid: string }) => {
const { theme } = useTheme();
if ((length === 0 && mounted) || !rid) {
if (length === 0 || !rid) {
return <ImageBackground source={{ uri: `message_empty_${theme}` }} style={styles.image} />;
}
return null;
});
export default EmptyRoom;

View File

@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { FlatListProps, StyleSheet } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import Animated, { runOnJS, useAnimatedScrollHandler } from 'react-native-reanimated';
import { isIOS } from '../../../../lib/methods/helpers';
import scrollPersistTaps from '../../../../lib/methods/helpers/scrollPersistTaps';
import NavBottomFAB from './NavBottomFAB';
import { IListProps } from '../definitions';
import { SCROLL_LIMIT } from '../constants';
import { TAnyMessageModel } from '../../../../definitions';
const AnimatedFlatList = Animated.createAnimatedComponent<FlatListProps<TAnyMessageModel>>(FlatList);
const styles = StyleSheet.create({
list: {
flex: 1
},
contentContainer: {
paddingTop: 10
}
});
export const List = ({ listRef, jumpToBottom, isThread, ...props }: IListProps) => {
const [visible, setVisible] = useState(false);
const scrollHandler = useAnimatedScrollHandler({
onScroll: event => {
if (event.contentOffset.y > SCROLL_LIMIT) {
runOnJS(setVisible)(true);
} else {
runOnJS(setVisible)(false);
}
}
});
return (
<>
<AnimatedFlatList
testID='room-view-messages'
// @ts-ignore createAnimatedComponent is making this fail
ref={listRef}
keyExtractor={item => item.id}
contentContainerStyle={styles.contentContainer}
style={styles.list}
inverted={isIOS}
removeClippedSubviews={isIOS}
initialNumToRender={7}
onEndReachedThreshold={0.5}
maxToRenderPerBatch={5}
windowSize={10}
scrollEventThrottle={16}
onScroll={scrollHandler}
{...props}
{...scrollPersistTaps}
/>
<NavBottomFAB visible={visible} onPress={jumpToBottom} isThread={isThread} />
</>
);
};

View File

@ -0,0 +1,68 @@
import React from 'react';
import { StyleSheet, View, Platform } from 'react-native';
import { CustomIcon } from '../../../../containers/CustomIcon';
import { useTheme } from '../../../../theme';
import Touch from '../../../../containers/Touch';
const styles = StyleSheet.create({
container: {
position: 'absolute',
right: 15
},
button: {
borderRadius: 25
},
content: {
width: 50,
height: 50,
borderRadius: 25,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center'
}
});
const NavBottomFAB = ({
visible,
onPress,
isThread
}: {
visible: boolean;
onPress: Function;
isThread: boolean;
}): React.ReactElement | null => {
const { colors } = useTheme();
if (!visible) {
return null;
}
return (
<View
style={[
styles.container,
{
...Platform.select({
ios: {
bottom: 100 + (isThread ? 40 : 0)
},
android: {
top: 15,
scaleY: -1
}
})
}
]}
testID='nav-jump-to-bottom'
>
<Touch onPress={() => onPress()} style={[styles.button, { backgroundColor: colors.backgroundColor }]}>
<View style={[styles.content, { borderColor: colors.borderColor }]}>
<CustomIcon name='chevron-down' color={colors.auxiliaryTintColor} size={36} />
</View>
</Touch>
</View>
);
};
export default NavBottomFAB;

View File

@ -1,8 +1,8 @@
import React from 'react';
import { RefreshControl as RNRefreshControl, RefreshControlProps, StyleSheet } from 'react-native';
import { useTheme } from '../../../theme';
import { isAndroid } from '../../../lib/methods/helpers';
import { useTheme } from '../../../../theme';
import { isAndroid } from '../../../../lib/methods/helpers';
const style = StyleSheet.create({
container: {
@ -17,7 +17,7 @@ interface IRefreshControl extends RefreshControlProps {
children: React.ReactElement;
}
const RefreshControl = ({ children, onRefresh, refreshing }: IRefreshControl): React.ReactElement => {
export const RefreshControl = ({ children, onRefresh, refreshing }: IRefreshControl): React.ReactElement => {
const { colors } = useTheme();
if (isAndroid) {
return (
@ -36,5 +36,3 @@ const RefreshControl = ({ children, onRefresh, refreshing }: IRefreshControl): R
return React.cloneElement(children, { refreshControl });
};
export default RefreshControl;

View File

@ -0,0 +1,4 @@
export * from './NavBottomFAB';
export * from './RefreshControl';
export * from './EmptyRoom';
export * from './List';

View File

@ -0,0 +1,7 @@
export const QUERY_SIZE = 50;
export const VIEWABILITY_CONFIG = {
itemVisiblePercentThreshold: 10
};
export const SCROLL_LIMIT = 200;

View File

@ -0,0 +1,31 @@
import { RefObject } from 'react';
import { FlatListProps } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import { TAnyMessageModel } from '../../../definitions';
export type TListRef = RefObject<FlatList<TAnyMessageModel>>;
export type TMessagesIdsRef = RefObject<string[]>;
export interface IListProps extends FlatListProps<TAnyMessageModel> {
listRef: TListRef;
jumpToBottom: () => void;
isThread: boolean;
}
export interface IListContainerRef {
jumpToMessage: (messageId: string) => Promise<void>;
cancelJumpToMessage: () => void;
}
export interface IListContainerProps {
renderRow: Function;
rid: string;
tmid?: string;
loading: boolean;
listRef: TListRef;
hideSystemMessages: string[];
showMessageInMainThread: boolean;
serverVersion: string | null;
}

View File

@ -0,0 +1,3 @@
export * from './useMessages';
export * from './useRefresh';
export * from './useScroll';

View File

@ -0,0 +1,111 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { Q } from '@nozbe/watermelondb';
import { Subscription } from 'rxjs';
import { TAnyMessageModel, TThreadModel } from '../../../../definitions';
import database from '../../../../lib/database';
import { getThreadById } from '../../../../lib/database/services/Thread';
import { animateNextTransition, compareServerVersion, isIOS, useDebounce } from '../../../../lib/methods/helpers';
import { Services } from '../../../../lib/services';
import { QUERY_SIZE } from '../constants';
export const useMessages = ({
rid,
tmid,
showMessageInMainThread,
serverVersion,
hideSystemMessages
}: {
rid: string;
tmid?: string;
showMessageInMainThread: boolean;
serverVersion: string | null;
hideSystemMessages: string[];
}) => {
const [messages, setMessages] = useState<TAnyMessageModel[]>([]);
const thread = useRef<TThreadModel | null>(null);
const count = useRef(0);
const subscription = useRef<Subscription | null>(null);
const messagesIds = useRef<string[]>([]);
const fetchMessages = useCallback(async () => {
unsubscribe();
count.current += QUERY_SIZE;
if (!rid) {
return;
}
const db = database.active;
let observable;
if (tmid) {
if (!thread.current) {
thread.current = await getThreadById(tmid);
}
observable = db
.get('thread_messages')
.query(Q.where('rid', tmid), Q.experimentalSortBy('ts', Q.desc), Q.experimentalSkip(0), Q.experimentalTake(count.current))
.observe();
} else {
const whereClause = [
Q.where('rid', rid),
Q.experimentalSortBy('ts', Q.desc),
Q.experimentalSkip(0),
Q.experimentalTake(count.current)
] as (Q.WhereDescription | Q.Or)[];
if (!showMessageInMainThread) {
whereClause.push(Q.or(Q.where('tmid', null), Q.where('tshow', Q.eq(true))));
}
observable = db
.get('messages')
.query(...whereClause)
.observe();
}
subscription.current = observable.subscribe(result => {
let newMessages: TAnyMessageModel[] = result;
if (tmid && thread.current) {
newMessages.push(thread.current);
}
/**
* Since 3.16.0 server version, the backend don't response with messages if
* hide system message is enabled
*/
if (compareServerVersion(serverVersion, 'lowerThan', '3.16.0') || hideSystemMessages.length) {
newMessages = newMessages.filter(m => !m.t || !hideSystemMessages?.includes(m.t));
}
readThread();
if (isIOS) {
animateNextTransition();
}
setMessages(newMessages);
messagesIds.current = newMessages.map(m => m.id);
});
}, [rid, tmid, showMessageInMainThread, serverVersion, hideSystemMessages]);
const readThread = useDebounce(async () => {
if (tmid) {
try {
await Services.readThreads(tmid);
} catch {
// Do nothing
}
}
}, 1000);
useLayoutEffect(() => {
fetchMessages();
return () => {
unsubscribe();
};
}, [rid, tmid, showMessageInMainThread, serverVersion, hideSystemMessages, fetchMessages]);
const unsubscribe = () => {
subscription.current?.unsubscribe();
};
return [messages, messagesIds, fetchMessages] as const;
};

View File

@ -0,0 +1,27 @@
import moment from 'moment';
import { useState } from 'react';
import log from '../../../../lib/methods/helpers/log';
import { loadMissedMessages, loadThreadMessages } from '../../../../lib/methods';
export const useRefresh = ({ rid, tmid, messagesLength }: { rid: string; tmid?: string; messagesLength: number }) => {
const [refreshing, setRefreshing] = useState(false);
const refresh = async () => {
if (messagesLength) {
setRefreshing(true);
try {
if (tmid) {
await loadThreadMessages({ tmid, rid });
} else {
await loadMissedMessages({ rid, lastOpen: moment().subtract(7, 'days').toDate() });
}
} catch (e) {
log(e);
}
setRefreshing(false);
}
};
return [refreshing, refresh] as const;
};

View File

@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from 'react';
import { ViewToken, ViewabilityConfigCallbackPairs } from 'react-native';
import { IListContainerRef, IListProps, TListRef, TMessagesIdsRef } from '../definitions';
import { VIEWABILITY_CONFIG } from '../constants';
export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; messagesIds: TMessagesIdsRef }) => {
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null);
const cancelJump = useRef(false);
const jumping = useRef(false);
const viewableItems = useRef<ViewToken[] | null>(null);
const highlightTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (highlightTimeout.current) {
clearTimeout(highlightTimeout.current);
}
});
const jumpToBottom = () => {
listRef.current?.scrollToOffset({ offset: -100 });
};
const onViewableItemsChanged: IListProps['onViewableItemsChanged'] = ({ viewableItems: vi }) => {
viewableItems.current = vi;
};
const viewabilityConfigCallbackPairs = useRef<ViewabilityConfigCallbackPairs>([
{ onViewableItemsChanged, viewabilityConfig: VIEWABILITY_CONFIG }
]);
const handleScrollToIndexFailed: IListProps['onScrollToIndexFailed'] = params => {
listRef.current?.scrollToIndex({ index: params.highestMeasuredFrameIndex, animated: false });
};
const setHighlightTimeout = () => {
if (highlightTimeout.current) {
clearTimeout(highlightTimeout.current);
}
highlightTimeout.current = setTimeout(() => {
setHighlightedMessageId(null);
}, 5000);
};
const jumpToMessage: IListContainerRef['jumpToMessage'] = messageId =>
new Promise<void>(async resolve => {
// if jump to message was cancelled, reset variables and stop
if (cancelJump.current) {
resetJumpToMessage();
return resolve();
}
jumping.current = true;
// look for the message on the state
const index = messagesIds.current?.findIndex(item => item === messageId);
// if found message, scroll to it
if (index && index > -1) {
listRef.current?.scrollToIndex({ index, viewPosition: 0.5, viewOffset: 100 });
// wait for scroll animation to finish
await new Promise(res => setTimeout(res, 300));
// if message is not visible
if (!viewableItems.current?.map(vi => vi.key).includes(messageId)) {
await setTimeout(() => resolve(jumpToMessage(messageId)), 300);
return;
}
// if message is visible, highlight it
setHighlightedMessageId(messageId);
setHighlightTimeout();
resetJumpToMessage();
resolve();
} else {
// if message not on state yet, scroll to top, so it triggers onEndReached and try again
listRef.current?.scrollToEnd();
await setTimeout(() => resolve(jumpToMessage(messageId)), 600);
}
});
const resetJumpToMessage = () => {
cancelJump.current = false;
jumping.current = false;
};
const cancelJumpToMessage: IListContainerRef['cancelJumpToMessage'] = () => {
if (jumping.current) {
cancelJump.current = true;
return;
}
resetJumpToMessage();
};
return {
jumpToBottom,
jumpToMessage,
cancelJumpToMessage,
viewabilityConfigCallbackPairs,
handleScrollToIndexFailed,
highlightedMessageId
};
};

View File

@ -1,25 +1,11 @@
import { Q } from '@nozbe/watermelondb';
import { dequal } from 'dequal';
import moment from 'moment';
import React from 'react';
import { FlatListProps, View, ViewToken, StyleSheet, Platform } from 'react-native';
import { event, Value } from 'react-native-reanimated';
import { Observable, Subscription } from 'rxjs';
import React, { forwardRef, useImperativeHandle } from 'react';
import { View, Platform, StyleSheet } from 'react-native';
import ActivityIndicator from '../../../containers/ActivityIndicator';
import { TAnyMessageModel, TMessageModel, TThreadMessageModel, TThreadModel } from '../../../definitions';
import database from '../../../lib/database';
import { compareServerVersion, debounce } from '../../../lib/methods/helpers';
import { animateNextTransition } from '../../../lib/methods/helpers/layoutAnimation';
import log from '../../../lib/methods/helpers/log';
import EmptyRoom from '../EmptyRoom';
import List, { IListProps, TListRef } from './List';
import NavBottomFAB from './NavBottomFAB';
import { loadMissedMessages, loadThreadMessages } from '../../../lib/methods';
import { Services } from '../../../lib/services';
import RefreshControl from './RefreshControl';
const QUERY_SIZE = 50;
import { useMessages, useRefresh, useScroll } from './hooks';
import { useDebounce } from '../../../lib/methods/helpers';
import { RefreshControl, EmptyRoom, List } from './components';
import { IListContainerProps, IListContainerRef, IListProps } from './definitions';
const styles = StyleSheet.create({
inverted: {
@ -31,367 +17,64 @@ const styles = StyleSheet.create({
}
});
const onScroll = ({ y }: { y: Value<number> }) =>
event(
[
{
nativeEvent: {
contentOffset: { y }
}
const ListContainer = forwardRef<IListContainerRef, IListContainerProps>(
({ rid, tmid, renderRow, showMessageInMainThread, serverVersion, hideSystemMessages, listRef, loading }, ref) => {
const [messages, messagesIds, fetchMessages] = useMessages({
rid,
tmid,
showMessageInMainThread,
serverVersion,
hideSystemMessages
});
const [refreshing, refresh] = useRefresh({ rid, tmid, messagesLength: messages.length });
const {
jumpToBottom,
jumpToMessage,
cancelJumpToMessage,
viewabilityConfigCallbackPairs,
handleScrollToIndexFailed,
highlightedMessageId
} = useScroll({ listRef, messagesIds });
const onEndReached = useDebounce(() => {
fetchMessages();
}, 300);
useImperativeHandle(ref, () => ({
jumpToMessage,
cancelJumpToMessage
}));
const renderFooter = () => {
if (loading && rid) {
return <ActivityIndicator />;
}
],
{ useNativeDriver: true }
);
export { IListProps };
export interface IListContainerProps {
renderRow: Function;
rid: string;
tmid?: string;
loading: boolean;
listRef: TListRef;
hideSystemMessages?: string[];
tunread?: string[];
ignored?: string[];
navigation: any; // TODO: type me
showMessageInMainThread: boolean;
serverVersion: string | null;
autoTranslateRoom?: boolean;
autoTranslateLanguage?: string;
}
interface IListContainerState {
messages: TAnyMessageModel[];
refreshing: boolean;
highlightedMessage: string | null;
}
class ListContainer extends React.Component<IListContainerProps, IListContainerState> {
private count = 0;
private mounted = false;
private animated = false;
private jumping = false;
private cancelJump = false;
private y = new Value(0);
private onScroll = onScroll({ y: this.y });
private unsubscribeFocus: () => void;
private viewabilityConfig = {
itemVisiblePercentThreshold: 10
};
private highlightedMessageTimeout: ReturnType<typeof setTimeout> | undefined | false;
private thread?: TThreadModel;
private messagesObservable?: Observable<TMessageModel[] | TThreadMessageModel[]>;
private messagesSubscription?: Subscription;
private viewableItems?: ViewToken[];
constructor(props: IListContainerProps) {
super(props);
console.time(`${this.constructor.name} init`);
console.time(`${this.constructor.name} mount`);
this.state = {
messages: [],
refreshing: false,
highlightedMessage: null
return null;
};
this.query();
this.unsubscribeFocus = props.navigation.addListener('focus', () => {
this.animated = true;
});
console.timeEnd(`${this.constructor.name} init`);
}
componentDidMount() {
this.mounted = true;
console.timeEnd(`${this.constructor.name} mount`);
}
const renderItem: IListProps['renderItem'] = ({ item, index }) => (
<View style={styles.inverted}>{renderRow(item, messages[index + 1], highlightedMessageId)}</View>
);
shouldComponentUpdate(nextProps: IListContainerProps, nextState: IListContainerState) {
const { refreshing, highlightedMessage } = this.state;
const { hideSystemMessages, tunread, ignored, loading, autoTranslateLanguage, autoTranslateRoom } = this.props;
if (loading !== nextProps.loading) {
return true;
}
if (highlightedMessage !== nextState.highlightedMessage) {
return true;
}
if (refreshing !== nextState.refreshing) {
return true;
}
if (!dequal(hideSystemMessages, nextProps.hideSystemMessages)) {
return true;
}
if (!dequal(tunread, nextProps.tunread)) {
return true;
}
if (!dequal(ignored, nextProps.ignored)) {
return true;
}
if (autoTranslateLanguage !== nextProps.autoTranslateLanguage || autoTranslateRoom !== nextProps.autoTranslateRoom) {
return true;
}
return false;
}
componentDidUpdate(prevProps: IListContainerProps) {
const { hideSystemMessages } = this.props;
if (!dequal(hideSystemMessages, prevProps.hideSystemMessages)) {
this.reload();
}
}
componentWillUnmount() {
this.unsubscribeMessages();
if (this.unsubscribeFocus) {
this.unsubscribeFocus();
}
this.clearHighlightedMessageTimeout();
console.countReset(`${this.constructor.name}.render calls`);
}
// clears previous highlighted message timeout, if exists
clearHighlightedMessageTimeout = () => {
if (this.highlightedMessageTimeout) {
clearTimeout(this.highlightedMessageTimeout);
this.highlightedMessageTimeout = false;
}
};
query = async () => {
this.count += QUERY_SIZE;
const { rid, tmid, showMessageInMainThread, serverVersion } = this.props;
const db = database.active;
// handle servers with version < 3.0.0
let { hideSystemMessages = [] } = this.props;
if (!Array.isArray(hideSystemMessages)) {
hideSystemMessages = [];
}
if (tmid) {
try {
this.thread = await db.get('threads').find(tmid);
} catch (e) {
console.log(e);
}
this.messagesObservable = db
.get('thread_messages')
.query(Q.where('rid', tmid), Q.experimentalSortBy('ts', Q.desc), Q.experimentalSkip(0), Q.experimentalTake(this.count))
.observe();
} else if (rid) {
const whereClause = [
Q.where('rid', rid),
Q.experimentalSortBy('ts', Q.desc),
Q.experimentalSkip(0),
Q.experimentalTake(this.count)
] as (Q.WhereDescription | Q.Or)[];
if (!showMessageInMainThread) {
whereClause.push(Q.or(Q.where('tmid', null), Q.where('tshow', Q.eq(true))));
}
this.messagesObservable = db
.get('messages')
.query(...whereClause)
.observe();
}
if (rid) {
this.unsubscribeMessages();
this.messagesSubscription = this.messagesObservable?.subscribe(messages => {
if (tmid && this.thread) {
messages = [...messages, this.thread];
}
/**
* Since 3.16.0 server version, the backend don't response with messages if
* hide system message is enabled
*/
if (compareServerVersion(serverVersion, 'lowerThan', '3.16.0') || hideSystemMessages.length) {
messages = messages.filter(m => !m.t || !hideSystemMessages?.includes(m.t));
}
if (this.mounted) {
this.setState({ messages }, () => this.update());
} else {
// @ts-ignore
this.state.messages = messages;
}
// TODO: move it away from here
this.readThreads();
});
}
};
reload = () => {
this.count = 0;
this.query();
};
readThreads = debounce(async () => {
const { tmid } = this.props;
if (tmid) {
try {
await Services.readThreads(tmid);
} catch {
// Do nothing
}
}
}, 300);
onEndReached = () => this.query();
onRefresh = () =>
this.setState({ refreshing: true }, async () => {
const { messages } = this.state;
const { rid, tmid } = this.props;
if (messages.length) {
try {
if (tmid) {
await loadThreadMessages({ tmid, rid });
} else {
await loadMissedMessages({ rid, lastOpen: moment().subtract(7, 'days').toDate() });
}
} catch (e) {
log(e);
}
}
this.setState({ refreshing: false });
});
update = () => {
if (this.animated) {
animateNextTransition();
}
this.forceUpdate();
};
unsubscribeMessages = () => {
if (this.messagesSubscription && this.messagesSubscription.unsubscribe) {
this.messagesSubscription.unsubscribe();
}
};
getLastMessage = (): TMessageModel | TThreadMessageModel | null => {
const { messages } = this.state;
if (messages.length > 0) {
return messages[0];
}
return null;
};
handleScrollToIndexFailed: FlatListProps<any>['onScrollToIndexFailed'] = params => {
const { listRef } = this.props;
listRef.current?.scrollToIndex({ index: params.highestMeasuredFrameIndex, animated: false });
};
jumpToMessage = (messageId: string) =>
new Promise<void>(async resolve => {
const { messages } = this.state;
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);
// if found message, scroll to it
if (index > -1) {
listRef.current?.scrollToIndex({ index, viewPosition: 0.5, viewOffset: 100 });
// wait for scroll animation to finish
await new Promise(res => setTimeout(res, 300));
// if message is not visible
if (!this.viewableItems?.map(vi => vi.key).includes(messageId)) {
await setTimeout(() => resolve(this.jumpToMessage(messageId)), 300);
return;
}
// if message is visible, highlight it
this.setState({ highlightedMessage: messageId });
this.clearHighlightedMessageTimeout();
// clears highlighted message after some time
this.highlightedMessageTimeout = setTimeout(() => {
this.setState({ highlightedMessage: null });
}, 5000);
this.resetJumpToMessage();
resolve();
} else {
// if message not found, wait for scroll to top and then jump to message
listRef.current?.scrollToIndex({ index: messages.length - 1, animated: true });
await setTimeout(() => resolve(this.jumpToMessage(messageId)), 300);
}
});
resetJumpToMessage = () => {
this.cancelJump = false;
this.jumping = false;
};
cancelJumpToMessage = () => {
if (this.jumping) {
this.cancelJump = true;
return;
}
this.resetJumpToMessage();
};
jumpToBottom = () => {
const { listRef } = this.props;
listRef.current?.scrollToOffset({ offset: -100 });
};
renderFooter = () => {
const { rid, loading } = this.props;
if (loading && rid) {
return <ActivityIndicator />;
}
return null;
};
renderItem: FlatListProps<any>['renderItem'] = ({ item, index }) => {
const { messages, highlightedMessage } = this.state;
const { renderRow } = this.props;
return <View style={styles.inverted}>{renderRow(item, messages[index + 1], highlightedMessage)}</View>;
};
onViewableItemsChanged: FlatListProps<any>['onViewableItemsChanged'] = ({ viewableItems }) => {
this.viewableItems = viewableItems;
};
render() {
console.count(`${this.constructor.name}.render calls`);
const { rid, tmid, listRef } = this.props;
const { messages, refreshing } = this.state;
return (
<>
<EmptyRoom rid={rid} length={messages.length} mounted={this.mounted} />
<RefreshControl refreshing={refreshing} onRefresh={this.onRefresh}>
<EmptyRoom rid={rid} length={messages.length} />
<RefreshControl refreshing={refreshing} onRefresh={refresh}>
<List
onScroll={this.onScroll}
scrollEventThrottle={16}
listRef={listRef}
data={messages}
renderItem={this.renderItem}
onEndReached={this.onEndReached}
ListFooterComponent={this.renderFooter}
onScrollToIndexFailed={this.handleScrollToIndexFailed}
onViewableItemsChanged={this.onViewableItemsChanged}
viewabilityConfig={this.viewabilityConfig}
renderItem={renderItem}
onEndReached={onEndReached}
ListFooterComponent={renderFooter}
onScrollToIndexFailed={handleScrollToIndexFailed}
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current}
jumpToBottom={jumpToBottom}
isThread={!!tmid}
/>
</RefreshControl>
<NavBottomFAB y={this.y} onPress={this.jumpToBottom} isThread={!!tmid} />
</>
);
}
}
export type ListContainerType = ListContainer;
);
export default ListContainer;

View File

@ -29,7 +29,6 @@ import { showErrorAlert } from '../../lib/methods/helpers/info';
import { withTheme } from '../../theme';
import {
KEY_COMMAND,
handleCommandReplyLatest,
handleCommandRoomActions,
handleCommandScroll,
handleCommandSearchMessages,
@ -56,7 +55,7 @@ import styles from './styles';
import JoinCode, { IJoinCode } from './JoinCode';
import UploadProgress from './UploadProgress';
import ReactionPicker from './ReactionPicker';
import List, { ListContainerType } from './List';
import List from './List';
import { ChatsStackParamList } from '../../stacks/types';
import {
IApplicationState,
@ -79,7 +78,6 @@ import {
RoomType
} from '../../definitions';
import { E2E_MESSAGE_TYPE, E2E_STATUS, MESSAGE_TYPE_ANY_LOAD, MessageTypeLoad, themes } from '../../lib/constants';
import { TListRef } from './List/List';
import { ModalStackParamList } from '../../stacks/MasterDetailStack/types';
import {
callJitsi,
@ -102,6 +100,7 @@ import {
import { Services } from '../../lib/services';
import { withActionSheet, IActionSheetProvider } from '../../containers/ActionSheet';
import { goRoom, TGoRoomItem } from '../../lib/methods/helpers/goRoom';
import { IListContainerRef, TListRef } from './List/definitions';
type TStateAttrsUpdate = keyof IRoomViewState;
@ -154,7 +153,6 @@ const roomAttrsUpdate = [
interface IRoomViewProps extends IActionSheetProvider, IBaseScreen<ChatsStackParamList, 'RoomView'> {
user: Pick<ILoggedUser, 'id' | 'username' | 'token' | 'showMessageInMainThread'>;
appState: string;
useRealName?: boolean;
isAuthenticated: boolean;
Message_GroupingPeriod?: number;
@ -214,8 +212,10 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
private jumpToMessageId?: string;
private jumpToThreadId?: string;
private messagebox: React.RefObject<MessageBoxType>;
private list: React.RefObject<ListContainerType>;
private joinCode: React.RefObject<IJoinCode>;
// ListContainer component
private list: React.RefObject<IListContainerRef>;
// FlatList inside ListContainer
private flatList: TListRef;
private mounted: boolean;
private offset = 0;
@ -224,8 +224,6 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
private queryUnreads?: Subscription;
private retryInit = 0;
private retryInitTimeout?: ReturnType<typeof setTimeout>;
private retryFindCount = 0;
private retryFindTimeout?: ReturnType<typeof setTimeout>;
private messageErrorActions?: IMessageErrorActions | null;
private messageActions?: IMessageActions | null;
private replyInDM?: TAnyMessageModel;
@ -239,8 +237,6 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
constructor(props: IRoomViewProps) {
super(props);
console.time(`${this.constructor.name} init`);
console.time(`${this.constructor.name} mount`);
this.rid = props.route.params?.rid;
this.t = props.route.params?.t;
/**
@ -312,7 +308,6 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
if (this.rid && !this.tmid) {
this.sub = new RoomClass(this.rid);
}
console.timeEnd(`${this.constructor.name} init`);
}
componentDidMount() {
@ -345,19 +340,15 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
EventEmitter.addEventListener(KEY_COMMAND, this.handleCommands);
}
EventEmitter.addEventListener('ROOM_REMOVED', this.handleRoomRemoved);
console.timeEnd(`${this.constructor.name} mount`);
}
shouldComponentUpdate(nextProps: IRoomViewProps, nextState: IRoomViewState) {
const { state } = this;
const { roomUpdate, member, isOnHold } = state;
const { appState, theme, insets, route } = this.props;
const { theme, insets, route } = this.props;
if (theme !== nextProps.theme) {
return true;
}
if (appState !== nextProps.appState) {
return true;
}
if (member.statusText !== nextState.member.statusText) {
return true;
}
@ -379,7 +370,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
componentDidUpdate(prevProps: IRoomViewProps, prevState: IRoomViewState) {
const { roomUpdate, joined } = this.state;
const { appState, insets, route } = this.props;
const { insets, route } = this.props;
if (route?.params?.jumpToMessageId && route?.params?.jumpToMessageId !== prevProps.route?.params?.jumpToMessageId) {
this.jumpToMessage(route?.params?.jumpToMessageId);
@ -389,12 +380,6 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
this.navToThread({ tmid: route?.params?.jumpToThreadId });
}
if (appState === 'foreground' && appState !== prevProps.appState && this.rid) {
// Fire List.query() just to keep observables working
if (this.list && this.list.current && !isIOS) {
this.list.current?.query();
}
}
// If it's a livechat room
if (this.t === 'l') {
if (
@ -476,7 +461,6 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
EventEmitter.removeListener(KEY_COMMAND, this.handleCommands);
}
EventEmitter.removeListener('ROOM_REMOVED', this.handleRoomRemoved);
console.countReset(`${this.constructor.name}.render calls`);
}
canForwardGuest = async () => {
@ -532,6 +516,18 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
return room.t === 'l';
}
get hideSystemMessages() {
const { sysMes } = this.state.room;
const { Hide_System_Messages } = this.props;
// FIXME: handle servers with version < 3.0.0
let hideSystemMessages = Array.isArray(sysMes) ? sysMes : Hide_System_Messages;
if (!Array.isArray(hideSystemMessages)) {
hideSystemMessages = [];
}
return hideSystemMessages ?? [];
}
setHeader = () => {
const { room, unreadsCount, roomUserId, joined, canForwardGuest, canReturnQueue, canPlaceLivechatOnHold } = this.state;
const { navigation, isMasterDetail, theme, baseUrl, user, route } = this.props;
@ -1064,9 +1060,6 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
const { rid } = this.state.room;
const { user } = this.props;
sendMessage(rid, message, this.tmid || tmid, user, tshow).then(() => {
if (this.list && this.list.current) {
this.list.current?.update();
}
this.setLastOpen(null);
Review.pushPositiveEvent();
});
@ -1260,13 +1253,6 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
this.goRoomActionsView();
} else if (handleCommandSearchMessages(event)) {
this.goRoomActionsView('SearchMessagesView');
} else if (handleCommandReplyLatest(event)) {
if (this.list && this.list.current) {
const message = this.list.current.getLastMessage();
if (message) {
this.onReplyInit(message, false);
}
}
}
}
};
@ -1531,17 +1517,13 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
};
render() {
console.count(`${this.constructor.name}.render calls`);
const { room, loading, canAutoTranslate } = this.state;
const { user, baseUrl, theme, navigation, Hide_System_Messages, width, serverVersion } = this.props;
const { room, loading } = this.state;
const { user, baseUrl, theme, width, serverVersion } = this.props;
const { rid, t } = room;
let sysMes;
let bannerClosed;
let announcement;
let tunread;
let ignored;
if ('id' in room) {
({ sysMes, bannerClosed, announcement, tunread, ignored } = room);
({ bannerClosed, announcement } = room);
}
return (
@ -1553,16 +1535,11 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
listRef={this.flatList}
rid={rid}
tmid={this.tmid}
tunread={tunread}
ignored={ignored}
renderRow={this.renderItem}
loading={loading}
navigation={navigation}
hideSystemMessages={Array.isArray(sysMes) ? sysMes : Hide_System_Messages}
hideSystemMessages={this.hideSystemMessages}
showMessageInMainThread={user.showMessageInMainThread ?? false}
serverVersion={serverVersion}
autoTranslateRoom={canAutoTranslate && 'id' in room && room.autoTranslate}
autoTranslateLanguage={'id' in room ? room.autoTranslateLanguage : undefined}
/>
{this.renderFooter()}
{this.renderActions()}
@ -1576,7 +1553,6 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
const mapStateToProps = (state: IApplicationState) => ({
user: getUserSelector(state),
isMasterDetail: state.app.isMasterDetail,
appState: state.app.ready && state.app.foreground ? 'foreground' : 'background',
useRealName: state.settings.UI_Use_Real_Name as boolean,
isAuthenticated: state.login.isAuthenticated,
Message_GroupingPeriod: state.settings.Message_GroupingPeriod as number,