diff --git a/native/chat/chat-item-height-measurer.react.js b/native/chat/chat-item-height-measurer.react.js
index e1a335074..195ae111d 100644
--- a/native/chat/chat-item-height-measurer.react.js
+++ b/native/chat/chat-item-height-measurer.react.js
@@ -1,167 +1,167 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import type { ChatMessageItem } from 'lib/selectors/chat-selectors';
import { messageID } from 'lib/shared/message-utils';
import { messageTypes, type MessageType } from 'lib/types/message-types';
import NodeHeightMeasurer from '../components/node-height-measurer.react';
import { InputStateContext } from '../input/input-state';
import type { MeasurementTask } from './chat-context-provider.react';
-import { chatMessageItemKey } from './chat-list.react';
+import { chatMessageItemKey } from './chat-message-constants';
import { useComposedMessageMaxWidth } from './composed-message-width';
import { dummyNodeForRobotextMessageHeightMeasurement } from './inner-robotext-message.react';
import { dummyNodeForTextMessageHeightMeasurement } from './inner-text-message.react';
import { MessageListContextProvider } from './message-list-types';
import { multimediaMessageContentSizes } from './multimedia-message-utils';
type Props = {
+measurement: MeasurementTask,
};
const heightMeasurerKey = (item: ChatMessageItem) => {
if (item.itemType !== 'message') {
return null;
}
const { messageInfo } = item;
if (messageInfo.type === messageTypes.TEXT) {
return messageInfo.text;
} else if (item.robotext && typeof item.robotext === 'string') {
return item.robotext;
}
return null;
};
const heightMeasurerDummy = (item: ChatMessageItem) => {
invariant(
item.itemType === 'message',
'NodeHeightMeasurer asked for dummy for non-message item',
);
const { messageInfo } = item;
if (messageInfo.type === messageTypes.TEXT) {
return dummyNodeForTextMessageHeightMeasurement(messageInfo.text);
} else if (item.robotext && typeof item.robotext === 'string') {
return dummyNodeForRobotextMessageHeightMeasurement(item.robotext);
}
invariant(false, 'NodeHeightMeasurer asked for dummy for non-text message');
};
function ChatItemHeightMeasurer(props: Props) {
const composedMessageMaxWidth = useComposedMessageMaxWidth();
const inputState = React.useContext(InputStateContext);
const inputStatePendingUploads = inputState?.pendingUploads;
const { measurement } = props;
const { threadInfo } = measurement;
const heightMeasurerMergeItem = React.useCallback(
(item: ChatMessageItem, height: ?number) => {
if (item.itemType !== 'message') {
return item;
}
const { messageInfo } = item;
const messageType: MessageType = messageInfo.type;
invariant(
messageType !== messageTypes.SIDEBAR_SOURCE,
'Sidebar source messages should be replaced by sourceMessage before being measured',
);
if (
messageInfo.type === messageTypes.IMAGES ||
messageInfo.type === messageTypes.MULTIMEDIA
) {
// Conditional due to Flow...
const localMessageInfo = item.localMessageInfo
? item.localMessageInfo
: null;
const id = messageID(messageInfo);
const pendingUploads = inputStatePendingUploads?.[id];
const sizes = multimediaMessageContentSizes(
messageInfo,
composedMessageMaxWidth,
);
return {
itemType: 'message',
messageShapeType: 'multimedia',
messageInfo,
localMessageInfo,
threadInfo,
startsConversation: item.startsConversation,
startsCluster: item.startsCluster,
endsCluster: item.endsCluster,
threadCreatedFromMessage: item.threadCreatedFromMessage,
pendingUploads,
...sizes,
};
}
invariant(
height !== null && height !== undefined,
'height should be set',
);
if (messageInfo.type === messageTypes.TEXT) {
// Conditional due to Flow...
const localMessageInfo = item.localMessageInfo
? item.localMessageInfo
: null;
return {
itemType: 'message',
messageShapeType: 'text',
messageInfo,
localMessageInfo,
threadInfo,
startsConversation: item.startsConversation,
startsCluster: item.startsCluster,
endsCluster: item.endsCluster,
threadCreatedFromMessage: item.threadCreatedFromMessage,
contentHeight: height,
};
} else {
invariant(
typeof item.robotext === 'string',
"Flow can't handle our fancy types :(",
);
return {
itemType: 'message',
messageShapeType: 'robotext',
messageInfo,
threadInfo,
startsConversation: item.startsConversation,
startsCluster: item.startsCluster,
endsCluster: item.endsCluster,
threadCreatedFromMessage: item.threadCreatedFromMessage,
robotext: item.robotext,
contentHeight: height,
};
}
},
[composedMessageMaxWidth, inputStatePendingUploads, threadInfo],
);
return (
);
}
const MemoizedChatItemHeightMeasurer: React.ComponentType = React.memo(
ChatItemHeightMeasurer,
);
export default MemoizedChatItemHeightMeasurer;
diff --git a/native/chat/chat-list.react.js b/native/chat/chat-list.react.js
index 36da3a28c..bdefc690c 100644
--- a/native/chat/chat-list.react.js
+++ b/native/chat/chat-list.react.js
@@ -1,306 +1,297 @@
// @flow
import invariant from 'invariant';
import _sum from 'lodash/fp/sum';
import * as React from 'react';
import {
FlatList,
Animated,
Easing,
StyleSheet,
TouchableWithoutFeedback,
View,
} from 'react-native';
-import type { ChatMessageItem } from 'lib/selectors/chat-selectors';
-import { localIDPrefix, messageKey } from 'lib/shared/message-utils';
+import { localIDPrefix } from 'lib/shared/message-utils';
import {
type KeyboardState,
KeyboardContext,
} from '../keyboard/keyboard-state';
import type { TabNavigationProp } from '../navigation/app-navigator.react';
import { useSelector } from '../redux/redux-utils';
import type { ChatMessageItemWithHeight } from '../types/chat-types';
import type { ScrollEvent } from '../types/react-native';
import type { ViewStyle } from '../types/styles';
+import { chatMessageItemKey } from './chat-message-constants';
import type { ChatNavigationProp } from './chat.react';
import NewMessagesPill from './new-messages-pill.react';
import { chatMessageItemHeight } from './utils';
-function chatMessageItemKey(
- item: ChatMessageItemWithHeight | ChatMessageItem,
-): string {
- if (item.itemType === 'loader') {
- return 'loader';
- }
- return messageKey(item.messageInfo);
-}
-
const animationSpec = {
duration: 150,
useNativeDriver: true,
};
type BaseProps = {
...React.ElementConfig,
+navigation: ChatNavigationProp<'MessageList'>,
+data: $ReadOnlyArray,
...
};
type Props = {
...BaseProps,
// Redux state
+viewerID: ?string,
// withKeyboardState
+keyboardState: ?KeyboardState,
...
};
type State = {
+newMessageCount: number,
};
class ChatList extends React.PureComponent {
state: State = {
newMessageCount: 0,
};
flatList: ?React.ElementRef;
scrollPos = 0;
newMessagesPillProgress = new Animated.Value(0);
newMessagesPillStyle: ViewStyle;
constructor(props: Props) {
super(props);
const sendButtonTranslateY = this.newMessagesPillProgress.interpolate({
inputRange: [0, 1],
outputRange: ([10, 0]: number[]), // Flow...
});
this.newMessagesPillStyle = {
opacity: this.newMessagesPillProgress,
transform: [{ translateY: sendButtonTranslateY }],
};
}
componentDidMount() {
const tabNavigation: ?TabNavigationProp<
'Chat',
> = this.props.navigation.dangerouslyGetParent();
invariant(tabNavigation, 'ChatNavigator should be within TabNavigator');
tabNavigation.addListener('tabPress', this.onTabPress);
}
componentWillUnmount() {
const tabNavigation: ?TabNavigationProp<
'Chat',
> = this.props.navigation.dangerouslyGetParent();
invariant(tabNavigation, 'ChatNavigator should be within TabNavigator');
tabNavigation.removeListener('tabPress', this.onTabPress);
}
onTabPress = () => {
const { flatList } = this;
if (!this.props.navigation.isFocused() || !flatList) {
return;
}
if (this.scrollPos > 0) {
flatList.scrollToOffset({ offset: 0 });
} else {
this.props.navigation.popToTop();
}
};
get scrolledToBottom() {
return this.scrollPos <= 0;
}
componentDidUpdate(prevProps: Props) {
const { flatList } = this;
if (!flatList || this.props.data === prevProps.data) {
return;
}
if (this.props.data.length < prevProps.data.length) {
// This should only happen due to MessageStorePruner,
// which will only prune a thread when it is off-screen
flatList.scrollToOffset({ offset: 0, animated: false });
return;
}
const { scrollPos } = this;
let curDataIndex = 0,
prevDataIndex = 0,
heightSoFar = 0;
let adjustScrollPos = 0,
newLocalMessage = false,
newRemoteMessageCount = 0;
while (prevDataIndex < prevProps.data.length && heightSoFar <= scrollPos) {
const prevItem = prevProps.data[prevDataIndex];
invariant(prevItem, 'prevItem should exist');
const prevItemKey = chatMessageItemKey(prevItem);
const prevItemHeight = chatMessageItemHeight(prevItem);
let curItem = this.props.data[curDataIndex];
while (curItem) {
const curItemKey = chatMessageItemKey(curItem);
if (curItemKey === prevItemKey) {
break;
}
if (curItemKey.startsWith(localIDPrefix)) {
newLocalMessage = true;
} else if (
curItem.itemType === 'message' &&
curItem.messageInfo.creator.id !== this.props.viewerID
) {
newRemoteMessageCount++;
}
adjustScrollPos += chatMessageItemHeight(curItem);
curDataIndex++;
curItem = this.props.data[curDataIndex];
}
if (!curItem) {
// Should never happen...
console.log(
`items not removed from ChatList, but ${prevItemKey} now missing`,
);
return;
}
const curItemHeight = chatMessageItemHeight(curItem);
adjustScrollPos += curItemHeight - prevItemHeight;
heightSoFar += prevItemHeight;
prevDataIndex++;
curDataIndex++;
}
if (adjustScrollPos === 0) {
return;
}
flatList.scrollToOffset({
offset: scrollPos + adjustScrollPos,
animated: false,
});
if (newLocalMessage || scrollPos <= 0) {
flatList.scrollToOffset({ offset: 0 });
} else if (newRemoteMessageCount > 0) {
this.setState(prevState => ({
newMessageCount: prevState.newMessageCount + newRemoteMessageCount,
}));
this.toggleNewMessagesPill(true);
}
}
render() {
const { navigation, viewerID, ...rest } = this.props;
const { newMessageCount } = this.state;
return (
0 ? 'auto' : 'none'}
containerStyle={styles.newMessagesPillContainer}
style={this.newMessagesPillStyle}
/>
);
}
flatListRef = (flatList: ?React.ElementRef) => {
this.flatList = flatList;
};
static getItemLayout = (
data: ?$ReadOnlyArray,
index: number,
) => {
if (!data) {
return { length: 0, offset: 0, index };
}
const offset = ChatList.heightOfItems(data.filter((_, i) => i < index));
const item = data[index];
const length = item ? chatMessageItemHeight(item) : 0;
return { length, offset, index };
};
static heightOfItems(
data: $ReadOnlyArray,
): number {
return _sum(data.map(chatMessageItemHeight));
}
toggleNewMessagesPill(show: boolean) {
Animated.timing(this.newMessagesPillProgress, {
...animationSpec,
// $FlowFixMe[method-unbinding]
easing: show ? Easing.ease : Easing.out(Easing.ease),
toValue: show ? 1 : 0,
}).start(({ finished }) => {
if (finished && !show) {
this.setState({ newMessageCount: 0 });
}
});
}
onScroll = (event: ScrollEvent) => {
this.scrollPos = event.nativeEvent.contentOffset.y;
if (this.scrollPos <= 0) {
this.toggleNewMessagesPill(false);
}
this.props.onScroll && this.props.onScroll(event);
};
onPressNewMessagesPill = () => {
const { flatList } = this;
if (!flatList) {
return;
}
flatList.scrollToOffset({ offset: 0 });
this.toggleNewMessagesPill(false);
};
onPressBackground = () => {
const { keyboardState } = this.props;
keyboardState && keyboardState.dismissKeyboard();
};
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
newMessagesPillContainer: {
bottom: 30,
position: 'absolute',
right: 30,
},
});
const ConnectedChatList: React.ComponentType = React.memo(
function ConnectedChatList(props: BaseProps) {
const keyboardState = React.useContext(KeyboardContext);
const viewerID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
return (
);
},
);
-export { ConnectedChatList as ChatList, chatMessageItemKey };
+export default ConnectedChatList;
diff --git a/native/chat/chat-message-constants.js b/native/chat/chat-message-constants.js
new file mode 100644
index 000000000..99b9d2fbf
--- /dev/null
+++ b/native/chat/chat-message-constants.js
@@ -0,0 +1,15 @@
+// @flow
+
+import type { ChatMessageItem } from 'lib/selectors/chat-selectors';
+import { messageKey } from 'lib/shared/message-utils';
+
+import type { ChatMessageItemWithHeight } from '../types/chat-types';
+
+export function chatMessageItemKey(
+ item: ChatMessageItemWithHeight | ChatMessageItem,
+): string {
+ if (item.itemType === 'loader') {
+ return 'loader';
+ }
+ return messageKey(item.messageInfo);
+}
diff --git a/native/chat/message-list.react.js b/native/chat/message-list.react.js
index 1470ef073..b79dacf7b 100644
--- a/native/chat/message-list.react.js
+++ b/native/chat/message-list.react.js
@@ -1,384 +1,384 @@
// @flow
import invariant from 'invariant';
import _find from 'lodash/fp/find';
import * as React from 'react';
import { View, TouchableWithoutFeedback } from 'react-native';
import { createSelector } from 'reselect';
import {
fetchMessagesBeforeCursorActionTypes,
fetchMessagesBeforeCursor,
fetchMostRecentMessagesActionTypes,
fetchMostRecentMessages,
} from 'lib/actions/message-actions';
import { registerFetchKey } from 'lib/reducers/loading-reducer';
import { messageKey } from 'lib/shared/message-utils';
import { useWatchThread } from 'lib/shared/thread-utils';
import type { FetchMessageInfosPayload } from 'lib/types/message-types';
import { type ThreadInfo, threadTypes } from 'lib/types/thread-types';
import {
type DispatchActionPromise,
useServerCall,
useDispatchActionPromise,
} from 'lib/utils/action-utils';
import ListLoadingIndicator from '../components/list-loading-indicator.react';
import {
type KeyboardState,
KeyboardContext,
} from '../keyboard/keyboard-state';
import { defaultStackScreenOptions } from '../navigation/options';
import {
OverlayContext,
type OverlayContextType,
} from '../navigation/overlay-context';
import type { NavigationRoute } from '../navigation/route-names';
import { useSelector } from '../redux/redux-utils';
import {
useStyles,
type IndicatorStyle,
useIndicatorStyle,
} from '../themes/colors';
import type {
ChatMessageInfoItemWithHeight,
ChatMessageItemWithHeight,
} from '../types/chat-types';
import type { VerticalBounds } from '../types/layout-types';
import type { ViewableItemsChange } from '../types/react-native';
-import { ChatList } from './chat-list.react';
+import ChatList from './chat-list.react';
import type { ChatNavigationProp } from './chat.react';
import { Message } from './message.react';
import RelationshipPrompt from './relationship-prompt.react';
type BaseProps = {
+threadInfo: ThreadInfo,
+messageListData: $ReadOnlyArray,
+navigation: ChatNavigationProp<'MessageList'>,
+route: NavigationRoute<'MessageList'>,
};
type Props = {
...BaseProps,
// Redux state
+startReached: boolean,
+styles: typeof unboundStyles,
+indicatorStyle: IndicatorStyle,
// Redux dispatch functions
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+fetchMessagesBeforeCursor: (
threadID: string,
beforeMessageID: string,
) => Promise,
+fetchMostRecentMessages: (
threadID: string,
) => Promise,
// withOverlayContext
+overlayContext: ?OverlayContextType,
// withKeyboardState
+keyboardState: ?KeyboardState,
};
type State = {
+focusedMessageKey: ?string,
+messageListVerticalBounds: ?VerticalBounds,
+loadingFromScroll: boolean,
};
type PropsAndState = {
...Props,
...State,
};
type FlatListExtraData = {
messageListVerticalBounds: ?VerticalBounds,
focusedMessageKey: ?string,
navigation: ChatNavigationProp<'MessageList'>,
route: NavigationRoute<'MessageList'>,
};
class MessageList extends React.PureComponent {
state: State = {
focusedMessageKey: null,
messageListVerticalBounds: null,
loadingFromScroll: false,
};
flatListContainer: ?React.ElementRef;
flatListExtraDataSelector = createSelector(
(propsAndState: PropsAndState) => propsAndState.messageListVerticalBounds,
(propsAndState: PropsAndState) => propsAndState.focusedMessageKey,
(propsAndState: PropsAndState) => propsAndState.navigation,
(propsAndState: PropsAndState) => propsAndState.route,
(
messageListVerticalBounds: ?VerticalBounds,
focusedMessageKey: ?string,
navigation: ChatNavigationProp<'MessageList'>,
route: NavigationRoute<'MessageList'>,
) => ({
messageListVerticalBounds,
focusedMessageKey,
navigation,
route,
}),
);
get flatListExtraData(): FlatListExtraData {
return this.flatListExtraDataSelector({ ...this.props, ...this.state });
}
static getOverlayContext(props: Props) {
const { overlayContext } = props;
invariant(overlayContext, 'MessageList should have OverlayContext');
return overlayContext;
}
static scrollDisabled(props: Props) {
const overlayContext = MessageList.getOverlayContext(props);
return overlayContext.scrollBlockingModalStatus !== 'closed';
}
static modalOpen(props: Props) {
const overlayContext = MessageList.getOverlayContext(props);
return overlayContext.scrollBlockingModalStatus === 'open';
}
componentDidUpdate(prevProps: Props) {
const newListData = this.props.messageListData;
const oldListData = prevProps.messageListData;
if (
this.state.loadingFromScroll &&
(newListData.length > oldListData.length || this.props.startReached)
) {
this.setState({ loadingFromScroll: false });
}
const modalIsOpen = MessageList.modalOpen(this.props);
const modalWasOpen = MessageList.modalOpen(prevProps);
if (!modalIsOpen && modalWasOpen) {
this.setState({ focusedMessageKey: null });
}
if (defaultStackScreenOptions.gestureEnabled) {
const scrollIsDisabled = MessageList.scrollDisabled(this.props);
const scrollWasDisabled = MessageList.scrollDisabled(prevProps);
if (!scrollWasDisabled && scrollIsDisabled) {
this.props.navigation.setOptions({ gestureEnabled: false });
} else if (scrollWasDisabled && !scrollIsDisabled) {
this.props.navigation.setOptions({ gestureEnabled: true });
}
}
}
dismissKeyboard = () => {
const { keyboardState } = this.props;
keyboardState && keyboardState.dismissKeyboard();
};
renderItem = (row: { item: ChatMessageItemWithHeight, ... }) => {
if (row.item.itemType === 'loader') {
return (
);
}
const messageInfoItem: ChatMessageInfoItemWithHeight = row.item;
const {
messageListVerticalBounds,
focusedMessageKey,
navigation,
route,
} = this.flatListExtraData;
const focused =
messageKey(messageInfoItem.messageInfo) === focusedMessageKey;
return (
);
};
toggleMessageFocus = (inMessageKey: string) => {
if (this.state.focusedMessageKey === inMessageKey) {
this.setState({ focusedMessageKey: null });
} else {
this.setState({ focusedMessageKey: inMessageKey });
}
};
// Actually header, it's just that our FlatList is inverted
ListFooterComponent = () => ;
render() {
const { messageListData, startReached } = this.props;
const footer = startReached ? this.ListFooterComponent : undefined;
let relationshipPrompt = null;
if (this.props.threadInfo.type === threadTypes.PERSONAL) {
relationshipPrompt = (
);
}
return (
{relationshipPrompt}
);
}
flatListContainerRef = (
flatListContainer: ?React.ElementRef,
) => {
this.flatListContainer = flatListContainer;
};
onFlatListContainerLayout = () => {
const { flatListContainer } = this;
if (!flatListContainer) {
return;
}
flatListContainer.measure((x, y, width, height, pageX, pageY) => {
if (
height === null ||
height === undefined ||
pageY === null ||
pageY === undefined
) {
return;
}
this.setState({ messageListVerticalBounds: { height, y: pageY } });
});
};
onViewableItemsChanged = (info: ViewableItemsChange) => {
if (this.state.focusedMessageKey) {
let focusedMessageVisible = false;
for (const token of info.viewableItems) {
if (
token.item.itemType === 'message' &&
messageKey(token.item.messageInfo) === this.state.focusedMessageKey
) {
focusedMessageVisible = true;
break;
}
}
if (!focusedMessageVisible) {
this.setState({ focusedMessageKey: null });
}
}
const loader = _find({ key: 'loader' })(info.viewableItems);
if (!loader || this.state.loadingFromScroll) {
return;
}
this.setState({ loadingFromScroll: true });
const oldestMessageServerID = this.oldestMessageServerID();
const threadID = this.props.threadInfo.id;
if (oldestMessageServerID) {
this.props.dispatchActionPromise(
fetchMessagesBeforeCursorActionTypes,
this.props.fetchMessagesBeforeCursor(threadID, oldestMessageServerID),
);
} else {
this.props.dispatchActionPromise(
fetchMostRecentMessagesActionTypes,
this.props.fetchMostRecentMessages(threadID),
);
}
};
oldestMessageServerID(): ?string {
const data = this.props.messageListData;
for (let i = data.length - 1; i >= 0; i--) {
if (data[i].itemType === 'message' && data[i].messageInfo.id) {
return data[i].messageInfo.id;
}
}
return null;
}
}
const unboundStyles = {
container: {
backgroundColor: 'listBackground',
flex: 1,
},
header: {
height: 12,
},
listLoadingIndicator: {
flex: 1,
},
};
registerFetchKey(fetchMessagesBeforeCursorActionTypes);
registerFetchKey(fetchMostRecentMessagesActionTypes);
const ConnectedMessageList: React.ComponentType = React.memo(
function ConnectedMessageList(props: BaseProps) {
const keyboardState = React.useContext(KeyboardContext);
const overlayContext = React.useContext(OverlayContext);
const threadID = props.threadInfo.id;
const startReached = useSelector(
state =>
!!(
state.messageStore.threads[threadID] &&
state.messageStore.threads[threadID].startReached
),
);
const styles = useStyles(unboundStyles);
const indicatorStyle = useIndicatorStyle();
const dispatchActionPromise = useDispatchActionPromise();
const callFetchMessagesBeforeCursor = useServerCall(
fetchMessagesBeforeCursor,
);
const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages);
useWatchThread(props.threadInfo);
return (
);
},
);
export default ConnectedMessageList;