From 043f48ae548a8ace37b0e280264822d88230662c Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 21 Sep 2023 15:32:47 -0300 Subject: [PATCH] chore: Migrate RoomView/List to Hooks (#5207) --- app/views/RoomView/List/List.tsx | 49 -- app/views/RoomView/List/NavBottomFAB.tsx | 75 ---- .../{ => List/components}/EmptyRoom.tsx | 8 +- app/views/RoomView/List/components/List.tsx | 60 +++ .../RoomView/List/components/NavBottomFAB.tsx | 68 +++ .../List/{ => components}/RefreshControl.tsx | 8 +- app/views/RoomView/List/components/index.ts | 4 + app/views/RoomView/List/constants.ts | 7 + app/views/RoomView/List/definitions.ts | 31 ++ app/views/RoomView/List/hooks/index.ts | 3 + app/views/RoomView/List/hooks/useMessages.ts | 111 +++++ app/views/RoomView/List/hooks/useRefresh.ts | 27 ++ app/views/RoomView/List/hooks/useScroll.ts | 102 +++++ app/views/RoomView/List/index.tsx | 419 +++--------------- app/views/RoomView/index.tsx | 70 +-- 15 files changed, 493 insertions(+), 549 deletions(-) delete mode 100644 app/views/RoomView/List/List.tsx delete mode 100644 app/views/RoomView/List/NavBottomFAB.tsx rename app/views/RoomView/{ => List/components}/EmptyRoom.tsx (61%) create mode 100644 app/views/RoomView/List/components/List.tsx create mode 100644 app/views/RoomView/List/components/NavBottomFAB.tsx rename app/views/RoomView/List/{ => components}/RefreshControl.tsx (76%) create mode 100644 app/views/RoomView/List/components/index.ts create mode 100644 app/views/RoomView/List/constants.ts create mode 100644 app/views/RoomView/List/definitions.ts create mode 100644 app/views/RoomView/List/hooks/index.ts create mode 100644 app/views/RoomView/List/hooks/useMessages.ts create mode 100644 app/views/RoomView/List/hooks/useRefresh.ts create mode 100644 app/views/RoomView/List/hooks/useScroll.ts diff --git a/app/views/RoomView/List/List.tsx b/app/views/RoomView/List/List.tsx deleted file mode 100644 index cf235ee8d..000000000 --- a/app/views/RoomView/List/List.tsx +++ /dev/null @@ -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 }>; - -export interface IListProps extends FlatListProps { - listRef: TListRef; -} - -const List = ({ listRef, ...props }: IListProps) => ( - 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; diff --git a/app/views/RoomView/List/NavBottomFAB.tsx b/app/views/RoomView/List/NavBottomFAB.tsx deleted file mode 100644 index 695c4ba38..000000000 --- a/app/views/RoomView/List/NavBottomFAB.tsx +++ /dev/null @@ -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; - 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 ( - - - - - - - - ); -}; - -export default NavBottomFAB; diff --git a/app/views/RoomView/EmptyRoom.tsx b/app/views/RoomView/List/components/EmptyRoom.tsx similarity index 61% rename from app/views/RoomView/EmptyRoom.tsx rename to app/views/RoomView/List/components/EmptyRoom.tsx index 99ac35e45..a5dab0779 100644 --- a/app/views/RoomView/EmptyRoom.tsx +++ b/app/views/RoomView/List/components/EmptyRoom.tsx @@ -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 ; } return null; }); - -export default EmptyRoom; diff --git a/app/views/RoomView/List/components/List.tsx b/app/views/RoomView/List/components/List.tsx new file mode 100644 index 000000000..22c07570f --- /dev/null +++ b/app/views/RoomView/List/components/List.tsx @@ -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>(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 ( + <> + 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} + /> + + + ); +}; diff --git a/app/views/RoomView/List/components/NavBottomFAB.tsx b/app/views/RoomView/List/components/NavBottomFAB.tsx new file mode 100644 index 000000000..a0d9a65c6 --- /dev/null +++ b/app/views/RoomView/List/components/NavBottomFAB.tsx @@ -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 ( + + onPress()} style={[styles.button, { backgroundColor: colors.backgroundColor }]}> + + + + + + ); +}; + +export default NavBottomFAB; diff --git a/app/views/RoomView/List/RefreshControl.tsx b/app/views/RoomView/List/components/RefreshControl.tsx similarity index 76% rename from app/views/RoomView/List/RefreshControl.tsx rename to app/views/RoomView/List/components/RefreshControl.tsx index 2f6b28f35..ef72298ab 100644 --- a/app/views/RoomView/List/RefreshControl.tsx +++ b/app/views/RoomView/List/components/RefreshControl.tsx @@ -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; diff --git a/app/views/RoomView/List/components/index.ts b/app/views/RoomView/List/components/index.ts new file mode 100644 index 000000000..7f17ff1d9 --- /dev/null +++ b/app/views/RoomView/List/components/index.ts @@ -0,0 +1,4 @@ +export * from './NavBottomFAB'; +export * from './RefreshControl'; +export * from './EmptyRoom'; +export * from './List'; diff --git a/app/views/RoomView/List/constants.ts b/app/views/RoomView/List/constants.ts new file mode 100644 index 000000000..30aafb545 --- /dev/null +++ b/app/views/RoomView/List/constants.ts @@ -0,0 +1,7 @@ +export const QUERY_SIZE = 50; + +export const VIEWABILITY_CONFIG = { + itemVisiblePercentThreshold: 10 +}; + +export const SCROLL_LIMIT = 200; diff --git a/app/views/RoomView/List/definitions.ts b/app/views/RoomView/List/definitions.ts new file mode 100644 index 000000000..127ec702e --- /dev/null +++ b/app/views/RoomView/List/definitions.ts @@ -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>; + +export type TMessagesIdsRef = RefObject; + +export interface IListProps extends FlatListProps { + listRef: TListRef; + jumpToBottom: () => void; + isThread: boolean; +} + +export interface IListContainerRef { + jumpToMessage: (messageId: string) => Promise; + cancelJumpToMessage: () => void; +} + +export interface IListContainerProps { + renderRow: Function; + rid: string; + tmid?: string; + loading: boolean; + listRef: TListRef; + hideSystemMessages: string[]; + showMessageInMainThread: boolean; + serverVersion: string | null; +} diff --git a/app/views/RoomView/List/hooks/index.ts b/app/views/RoomView/List/hooks/index.ts new file mode 100644 index 000000000..12e970eca --- /dev/null +++ b/app/views/RoomView/List/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './useMessages'; +export * from './useRefresh'; +export * from './useScroll'; diff --git a/app/views/RoomView/List/hooks/useMessages.ts b/app/views/RoomView/List/hooks/useMessages.ts new file mode 100644 index 000000000..0f5fa0c15 --- /dev/null +++ b/app/views/RoomView/List/hooks/useMessages.ts @@ -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([]); + const thread = useRef(null); + const count = useRef(0); + const subscription = useRef(null); + const messagesIds = useRef([]); + + 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; +}; diff --git a/app/views/RoomView/List/hooks/useRefresh.ts b/app/views/RoomView/List/hooks/useRefresh.ts new file mode 100644 index 000000000..79b744d13 --- /dev/null +++ b/app/views/RoomView/List/hooks/useRefresh.ts @@ -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; +}; diff --git a/app/views/RoomView/List/hooks/useScroll.ts b/app/views/RoomView/List/hooks/useScroll.ts new file mode 100644 index 000000000..68a24446a --- /dev/null +++ b/app/views/RoomView/List/hooks/useScroll.ts @@ -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(null); + const cancelJump = useRef(false); + const jumping = useRef(false); + const viewableItems = useRef(null); + const highlightTimeout = useRef | 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([ + { 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(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 + }; +}; diff --git a/app/views/RoomView/List/index.tsx b/app/views/RoomView/List/index.tsx index 42c666ed9..6846f208f 100644 --- a/app/views/RoomView/List/index.tsx +++ b/app/views/RoomView/List/index.tsx @@ -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 }) => - event( - [ - { - nativeEvent: { - contentOffset: { y } - } +const ListContainer = forwardRef( + ({ 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 ; } - ], - { 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 { - 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 | undefined | false; - private thread?: TThreadModel; - private messagesObservable?: Observable; - 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 }) => ( + {renderRow(item, messages[index + 1], highlightedMessageId)} + ); - 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['onScrollToIndexFailed'] = params => { - const { listRef } = this.props; - listRef.current?.scrollToIndex({ index: params.highestMeasuredFrameIndex, animated: false }); - }; - - jumpToMessage = (messageId: string) => - new Promise(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 ; - } - return null; - }; - - renderItem: FlatListProps['renderItem'] = ({ item, index }) => { - const { messages, highlightedMessage } = this.state; - const { renderRow } = this.props; - return {renderRow(item, messages[index + 1], highlightedMessage)}; - }; - - onViewableItemsChanged: FlatListProps['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 ( <> - - + + - ); } -} - -export type ListContainerType = ListContainer; +); export default ListContainer; diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx index 7ca32dd21..408819a25 100644 --- a/app/views/RoomView/index.tsx +++ b/app/views/RoomView/index.tsx @@ -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 { user: Pick; - appState: string; useRealName?: boolean; isAuthenticated: boolean; Message_GroupingPeriod?: number; @@ -214,8 +212,10 @@ class RoomView extends React.Component { private jumpToMessageId?: string; private jumpToThreadId?: string; private messagebox: React.RefObject; - private list: React.RefObject; private joinCode: React.RefObject; + // ListContainer component + private list: React.RefObject; + // FlatList inside ListContainer private flatList: TListRef; private mounted: boolean; private offset = 0; @@ -224,8 +224,6 @@ class RoomView extends React.Component { private queryUnreads?: Subscription; private retryInit = 0; private retryInitTimeout?: ReturnType; - private retryFindCount = 0; - private retryFindTimeout?: ReturnType; private messageErrorActions?: IMessageErrorActions | null; private messageActions?: IMessageActions | null; private replyInDM?: TAnyMessageModel; @@ -239,8 +237,6 @@ class RoomView extends React.Component { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { }; 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 { 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 { 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,