diff --git a/native/chat/inline-sidebar-constants.js b/native/chat/chat-constants.js
similarity index 78%
rename from native/chat/inline-sidebar-constants.js
rename to native/chat/chat-constants.js
index 13152ef4e..46d2e8b6c 100644
--- a/native/chat/inline-sidebar-constants.js
+++ b/native/chat/chat-constants.js
@@ -1,5 +1,7 @@
// @flow
export const inlineSidebarHeight = 20;
export const inlineSidebarMarginTop = 5;
export const inlineSidebarMarginBottom = 3;
+
+export const clusterEndHeight = 7;
diff --git a/native/chat/chat-item-height-measurer.react.js b/native/chat/chat-item-height-measurer.react.js
index 195ae111d..edc3b12a1 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-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';
+import { chatMessageItemKey } from './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 bdefc690c..cc5a68662 100644
--- a/native/chat/chat-list.react.js
+++ b/native/chat/chat-list.react.js
@@ -1,297 +1,296 @@
// @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 { 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';
+import { chatMessageItemHeight, chatMessageItemKey } from './utils';
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 default ConnectedChatList;
diff --git a/native/chat/chat-message-constants.js b/native/chat/chat-message-constants.js
deleted file mode 100644
index 99b9d2fbf..000000000
--- a/native/chat/chat-message-constants.js
+++ /dev/null
@@ -1,15 +0,0 @@
-// @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/composed-message-constants.js b/native/chat/composed-message-constants.js
deleted file mode 100644
index 989885115..000000000
--- a/native/chat/composed-message-constants.js
+++ /dev/null
@@ -1,3 +0,0 @@
-// @flow
-
-export const clusterEndHeight = 7;
diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js
index b58f976fb..552cbeb11 100644
--- a/native/chat/composed-message.react.js
+++ b/native/chat/composed-message.react.js
@@ -1,224 +1,224 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { StyleSheet, View } from 'react-native';
import Animated from 'react-native-reanimated';
import Icon from 'react-native-vector-icons/Feather';
import { createMessageReply } from 'lib/shared/message-utils';
import { assertComposableMessageType } from 'lib/types/message-types';
import { type InputState, InputStateContext } from '../input/input-state';
import { type Colors, useColors } from '../themes/colors';
import type { ChatMessageInfoItemWithHeight } from '../types/chat-types';
import { type AnimatedStyleObj, AnimatedView } from '../types/styles';
-import { clusterEndHeight } from './composed-message-constants';
-import { useComposedMessageMaxWidth } from './composed-message-width';
-import { FailedSend } from './failed-send.react';
import {
- inlineSidebarMarginTop,
+ clusterEndHeight,
inlineSidebarMarginBottom,
-} from './inline-sidebar-constants';
+ inlineSidebarMarginTop,
+} from './chat-constants';
+import { useComposedMessageMaxWidth } from './composed-message-width';
+import { FailedSend } from './failed-send.react';
import InlineSidebar from './inline-sidebar.react';
import { MessageHeader } from './message-header.react';
import { useNavigateToSidebar } from './sidebar-navigation';
import SwipeableMessage from './swipeable-message.react';
import { useContentAndHeaderOpacity, useDeliveryIconOpacity } from './utils';
/* eslint-disable import/no-named-as-default-member */
const { Node } = Animated;
/* eslint-enable import/no-named-as-default-member */
type SwipeOptions = 'reply' | 'sidebar' | 'both' | 'none';
type BaseProps = {
...React.ElementConfig,
+item: ChatMessageInfoItemWithHeight,
+sendFailed: boolean,
+focused: boolean,
+swipeOptions: SwipeOptions,
+children: React.Node,
};
type Props = {
...BaseProps,
// Redux state
+composedMessageMaxWidth: number,
+colors: Colors,
+contentAndHeaderOpacity: number | Node,
+deliveryIconOpacity: number | Node,
// withInputState
+inputState: ?InputState,
+navigateToSidebar: () => void,
};
class ComposedMessage extends React.PureComponent {
render() {
assertComposableMessageType(this.props.item.messageInfo.type);
const {
item,
sendFailed,
focused,
swipeOptions,
children,
composedMessageMaxWidth,
colors,
inputState,
navigateToSidebar,
contentAndHeaderOpacity,
deliveryIconOpacity,
...viewProps
} = this.props;
const { id, creator } = item.messageInfo;
const { isViewer } = creator;
const alignStyle = isViewer
? styles.rightChatBubble
: styles.leftChatBubble;
const containerStyle = [
styles.alignment,
{ marginBottom: 5 + (item.endsCluster ? clusterEndHeight : 0) },
];
const messageBoxStyle = { maxWidth: composedMessageMaxWidth };
let deliveryIcon = null;
let failedSendInfo = null;
if (isViewer) {
let deliveryIconName;
let deliveryIconColor = `#${item.threadInfo.color}`;
if (id !== null && id !== undefined) {
deliveryIconName = 'check-circle';
} else if (sendFailed) {
deliveryIconName = 'x-circle';
deliveryIconColor = colors.redText;
failedSendInfo = ;
} else {
deliveryIconName = 'circle';
}
const animatedStyle: AnimatedStyleObj = { opacity: deliveryIconOpacity };
deliveryIcon = (
);
}
const triggerReply =
swipeOptions === 'reply' || swipeOptions === 'both'
? this.reply
: undefined;
const triggerSidebar =
swipeOptions === 'sidebar' || swipeOptions === 'both'
? navigateToSidebar
: undefined;
const messageBox = (
{children}
);
let inlineSidebar = null;
if (item.threadCreatedFromMessage) {
const positioning = isViewer ? 'right' : 'left';
inlineSidebar = (
);
}
return (
{deliveryIcon}
{messageBox}
{failedSendInfo}
{inlineSidebar}
);
}
reply = () => {
const { inputState, item } = this.props;
invariant(inputState, 'inputState should be set in reply');
invariant(item.messageInfo.text, 'text should be set in reply');
inputState.addReply(createMessageReply(item.messageInfo.text));
};
}
const styles = StyleSheet.create({
alignment: {
marginLeft: 12,
marginRight: 7,
},
content: {
alignItems: 'center',
flexDirection: 'row-reverse',
},
icon: {
fontSize: 16,
textAlign: 'center',
},
iconContainer: {
marginLeft: 2,
width: 16,
},
inlineSidebar: {
marginBottom: inlineSidebarMarginBottom,
marginTop: inlineSidebarMarginTop,
},
leftChatBubble: {
justifyContent: 'flex-end',
},
messageBox: {
marginRight: 5,
},
rightChatBubble: {
justifyContent: 'flex-start',
},
});
const ConnectedComposedMessage: React.ComponentType = React.memo(
function ConnectedComposedMessage(props: BaseProps) {
const composedMessageMaxWidth = useComposedMessageMaxWidth();
const colors = useColors();
const inputState = React.useContext(InputStateContext);
const navigateToSidebar = useNavigateToSidebar(props.item);
const contentAndHeaderOpacity = useContentAndHeaderOpacity(props.item);
const deliveryIconOpacity = useDeliveryIconOpacity(props.item);
return (
);
},
);
export default ConnectedComposedMessage;
diff --git a/native/chat/inline-sidebar.react.js b/native/chat/inline-sidebar.react.js
index 9824aa23e..eb7a506f5 100644
--- a/native/chat/inline-sidebar.react.js
+++ b/native/chat/inline-sidebar.react.js
@@ -1,99 +1,99 @@
// @flow
import * as React from 'react';
import { Text, View } from 'react-native';
import Icon from 'react-native-vector-icons/Feather';
import useInlineSidebarText from 'lib/hooks/inline-sidebar-text.react';
import type { ThreadInfo } from 'lib/types/thread-types';
import Button from '../components/button.react';
import { useStyles } from '../themes/colors';
-import { inlineSidebarHeight } from './inline-sidebar-constants';
+import { inlineSidebarHeight } from './chat-constants';
import { useNavigateToThread } from './message-list-types';
type Props = {
+threadInfo: ThreadInfo,
+positioning: 'left' | 'center' | 'right',
};
function InlineSidebar(props: Props): React.Node {
const { threadInfo } = props;
const { sendersText, repliesText } = useInlineSidebarText(threadInfo);
const navigateToThread = useNavigateToThread();
const onPress = React.useCallback(() => {
navigateToThread({ threadInfo });
}, [navigateToThread, threadInfo]);
const styles = useStyles(unboundStyles);
let viewerIcon, nonViewerIcon, alignStyle;
if (props.positioning === 'right') {
viewerIcon = ;
alignStyle = styles.rightAlign;
} else if (props.positioning === 'left') {
nonViewerIcon = (
);
alignStyle = styles.leftAlign;
} else {
nonViewerIcon = (
);
alignStyle = styles.centerAlign;
}
const unreadStyle = threadInfo.currentUser.unread ? styles.unread : null;
return (
);
}
const unboundStyles = {
content: {
flexDirection: 'row',
marginRight: 30,
marginLeft: 10,
flex: 1,
height: inlineSidebarHeight,
},
unread: {
color: 'listForegroundLabel',
fontWeight: 'bold',
},
sidebar: {
flexDirection: 'row',
display: 'flex',
alignItems: 'center',
},
icon: {
color: 'listForegroundTertiaryLabel',
},
name: {
paddingTop: 1,
color: 'listForegroundTertiaryLabel',
fontSize: 16,
paddingLeft: 4,
paddingRight: 2,
},
leftAlign: {
justifyContent: 'flex-start',
},
rightAlign: {
justifyContent: 'flex-end',
},
centerAlign: {
justifyContent: 'center',
},
};
export default InlineSidebar;
diff --git a/native/chat/message-header.react.js b/native/chat/message-header.react.js
index b26e80137..c791666e4 100644
--- a/native/chat/message-header.react.js
+++ b/native/chat/message-header.react.js
@@ -1,82 +1,82 @@
// @flow
import * as React from 'react';
import { View } from 'react-native';
import { stringForUser } from 'lib/shared/user-utils';
import { SingleLine } from '../components/single-line.react';
import { useStyles } from '../themes/colors';
import type { ChatMessageInfoItemWithHeight } from '../types/chat-types';
-import { clusterEndHeight } from './composed-message-constants';
+import { clusterEndHeight } from './chat-constants';
import type { DisplayType } from './timestamp.react';
import { Timestamp, timestampHeight } from './timestamp.react';
type Props = {
+item: ChatMessageInfoItemWithHeight,
+focused: boolean,
+display: DisplayType,
};
function MessageHeader(props: Props): React.Node {
const styles = useStyles(unboundStyles);
const { item, focused, display } = props;
const { creator, time } = item.messageInfo;
const { isViewer } = creator;
const modalDisplay = display === 'modal';
let authorName = null;
if (!isViewer && (modalDisplay || item.startsCluster)) {
const style = [styles.authorName];
if (modalDisplay) {
style.push(styles.modal);
}
authorName = (
{stringForUser(creator)}
);
}
const timestamp =
modalDisplay || item.startsConversation ? (
) : null;
let style = null;
if (focused && !modalDisplay) {
let topMargin = 0;
if (!item.startsCluster && !item.messageInfo.creator.isViewer) {
topMargin += authorNameHeight + clusterEndHeight;
}
if (!item.startsConversation) {
topMargin += timestampHeight;
}
style = { marginTop: topMargin };
}
return (
{timestamp}
{authorName}
);
}
const authorNameHeight = 25;
const unboundStyles = {
authorName: {
bottom: 0,
color: 'listBackgroundSecondaryLabel',
fontSize: 14,
height: authorNameHeight,
marginLeft: 12,
marginRight: 7,
paddingHorizontal: 12,
paddingVertical: 4,
},
modal: {
// high contrast framed against OverlayNavigator-dimmed background
color: 'white',
},
};
export { MessageHeader, authorNameHeight };
diff --git a/native/chat/multimedia-message-utils.js b/native/chat/multimedia-message-utils.js
index 6e0a5acdc..d11ab1bcd 100644
--- a/native/chat/multimedia-message-utils.js
+++ b/native/chat/multimedia-message-utils.js
@@ -1,146 +1,146 @@
// @flow
import invariant from 'invariant';
import { messageKey } from 'lib/shared/message-utils';
import type { MediaInfo } from 'lib/types/media-types';
import type { MultimediaMessageInfo } from 'lib/types/message-types';
import type {
ChatMultimediaMessageInfoItem,
MultimediaContentSizes,
} from '../types/chat-types';
-import { clusterEndHeight } from './composed-message-constants';
-import { failedSendHeight } from './failed-send.react';
import {
inlineSidebarMarginBottom,
inlineSidebarMarginTop,
inlineSidebarHeight,
-} from './inline-sidebar-constants';
+ clusterEndHeight,
+} from './chat-constants';
+import { failedSendHeight } from './failed-send.react';
import { authorNameHeight } from './message-header.react';
const spaceBetweenImages = 4;
function getMediaPerRow(mediaCount: number): number {
if (mediaCount === 0) {
return 0; // ???
} else if (mediaCount === 1) {
return 1;
} else if (mediaCount === 2) {
return 2;
} else if (mediaCount === 3) {
return 3;
} else if (mediaCount === 4) {
return 2;
} else {
return 3;
}
}
function multimediaMessageSendFailed(
item: ChatMultimediaMessageInfoItem,
): boolean {
const { messageInfo, localMessageInfo, pendingUploads } = item;
const { id: serverID } = messageInfo;
if (serverID !== null && serverID !== undefined) {
return false;
}
const { isViewer } = messageInfo.creator;
if (!isViewer) {
return false;
}
if (localMessageInfo && localMessageInfo.sendFailed) {
return true;
}
for (const media of messageInfo.media) {
const pendingUpload = pendingUploads && pendingUploads[media.id];
if (pendingUpload && pendingUpload.failed) {
return true;
}
}
return !pendingUploads;
}
// The results are merged into ChatMultimediaMessageInfoItem
function multimediaMessageContentSizes(
messageInfo: MultimediaMessageInfo,
composedMessageMaxWidth: number,
): MultimediaContentSizes {
invariant(messageInfo.media.length > 0, 'should have media');
if (messageInfo.media.length === 1) {
const [media] = messageInfo.media;
const { height, width } = media.dimensions;
let imageHeight = height;
if (width > composedMessageMaxWidth) {
imageHeight = (height * composedMessageMaxWidth) / width;
}
if (imageHeight < 50) {
imageHeight = 50;
}
let contentWidth = height ? (width * imageHeight) / height : 0;
if (contentWidth > composedMessageMaxWidth) {
contentWidth = composedMessageMaxWidth;
}
return { imageHeight, contentHeight: imageHeight, contentWidth };
}
const contentWidth = composedMessageMaxWidth;
const mediaPerRow = getMediaPerRow(messageInfo.media.length);
const marginSpace = spaceBetweenImages * (mediaPerRow - 1);
const imageHeight = (contentWidth - marginSpace) / mediaPerRow;
const numRows = Math.ceil(messageInfo.media.length / mediaPerRow);
const contentHeight =
numRows * imageHeight + (numRows - 1) * spaceBetweenImages;
return { imageHeight, contentHeight, contentWidth };
}
// Given a ChatMultimediaMessageInfoItem, determines exact height of row
function multimediaMessageItemHeight(
item: ChatMultimediaMessageInfoItem,
): number {
const { messageInfo, contentHeight, startsCluster, endsCluster } = item;
const { creator } = messageInfo;
const { isViewer } = creator;
let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage
if (!isViewer && startsCluster) {
height += authorNameHeight;
}
if (endsCluster) {
height += clusterEndHeight;
}
if (multimediaMessageSendFailed(item)) {
height += failedSendHeight;
}
if (item.threadCreatedFromMessage) {
height +=
inlineSidebarHeight + inlineSidebarMarginTop + inlineSidebarMarginBottom;
}
return height;
}
function getMediaKey(
item: ChatMultimediaMessageInfoItem,
mediaInfo: MediaInfo,
): string {
return `multimedia|${messageKey(item.messageInfo)}|${mediaInfo.index}`;
}
export {
multimediaMessageContentSizes,
multimediaMessageItemHeight,
multimediaMessageSendFailed,
getMediaPerRow,
spaceBetweenImages,
getMediaKey,
};
diff --git a/native/chat/utils.js b/native/chat/utils.js
index 3d7beee22..745b88620 100644
--- a/native/chat/utils.js
+++ b/native/chat/utils.js
@@ -1,412 +1,423 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import Animated from 'react-native-reanimated';
import { useMessageListData } from 'lib/selectors/chat-selectors';
+import type { ChatMessageItem } from 'lib/selectors/chat-selectors';
import { messageKey } from 'lib/shared/message-utils';
import { colorIsDark, viewerIsMember } from 'lib/shared/thread-utils';
import type { ThreadInfo } from 'lib/types/thread-types';
import { KeyboardContext } from '../keyboard/keyboard-state';
import { OverlayContext } from '../navigation/overlay-context';
import {
MultimediaMessageTooltipModalRouteName,
RobotextMessageTooltipModalRouteName,
TextMessageTooltipModalRouteName,
} from '../navigation/route-names';
import { useSelector } from '../redux/redux-utils';
import type {
ChatMessageInfoItemWithHeight,
ChatMessageItemWithHeight,
ChatRobotextMessageInfoItemWithHeight,
ChatTextMessageInfoItemWithHeight,
} from '../types/chat-types';
import type { LayoutCoordinates, VerticalBounds } from '../types/layout-types';
import type { AnimatedViewStyle } from '../types/styles';
-import { ChatContext, useHeightMeasurer } from './chat-context';
-import { clusterEndHeight } from './composed-message-constants';
-import { failedSendHeight } from './failed-send.react';
import {
+ clusterEndHeight,
inlineSidebarHeight,
inlineSidebarMarginBottom,
inlineSidebarMarginTop,
-} from './inline-sidebar-constants';
+} from './chat-constants';
+import { ChatContext, useHeightMeasurer } from './chat-context';
+import { failedSendHeight } from './failed-send.react';
import { authorNameHeight } from './message-header.react';
import { multimediaMessageItemHeight } from './multimedia-message-utils';
import { getSidebarThreadInfo } from './sidebar-navigation';
import textMessageSendFailed from './text-message-send-failed';
import { timestampHeight } from './timestamp.react';
/* eslint-disable import/no-named-as-default-member */
const {
Node,
Extrapolate,
interpolateNode,
interpolateColors,
block,
call,
eq,
cond,
sub,
} = Animated;
/* eslint-enable import/no-named-as-default-member */
function textMessageItemHeight(
item: ChatTextMessageInfoItemWithHeight,
): number {
const { messageInfo, contentHeight, startsCluster, endsCluster } = item;
const { isViewer } = messageInfo.creator;
let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage
if (!isViewer && startsCluster) {
height += authorNameHeight;
}
if (endsCluster) {
height += clusterEndHeight;
}
if (textMessageSendFailed(item)) {
height += failedSendHeight;
}
if (item.threadCreatedFromMessage) {
height +=
inlineSidebarHeight + inlineSidebarMarginTop + inlineSidebarMarginBottom;
}
return height;
}
function robotextMessageItemHeight(
item: ChatRobotextMessageInfoItemWithHeight,
): number {
if (item.threadCreatedFromMessage) {
return item.contentHeight + inlineSidebarHeight;
}
return item.contentHeight;
}
function messageItemHeight(item: ChatMessageInfoItemWithHeight): number {
let height = 0;
if (item.messageShapeType === 'text') {
height += textMessageItemHeight(item);
} else if (item.messageShapeType === 'multimedia') {
height += multimediaMessageItemHeight(item);
} else {
height += robotextMessageItemHeight(item);
}
if (item.startsConversation) {
height += timestampHeight;
}
return height;
}
function chatMessageItemHeight(item: ChatMessageItemWithHeight): number {
if (item.itemType === 'loader') {
return 56;
}
return messageItemHeight(item);
}
function useMessageTargetParameters(
sourceMessage: ChatMessageInfoItemWithHeight,
initialCoordinates: LayoutCoordinates,
messageListVerticalBounds: VerticalBounds,
currentInputBarHeight: number,
targetInputBarHeight: number,
sidebarThreadInfo: ?ThreadInfo,
): {
+position: number,
+color: string,
} {
const messageListData = useMessageListData({
searching: false,
userInfoInputArray: [],
threadInfo: sidebarThreadInfo,
});
const [
messagesWithHeight,
setMessagesWithHeight,
] = React.useState$ReadOnlyArray>(null);
const measureMessages = useHeightMeasurer();
React.useEffect(() => {
if (messageListData) {
measureMessages(
messageListData,
sidebarThreadInfo,
setMessagesWithHeight,
);
}
}, [measureMessages, messageListData, sidebarThreadInfo]);
const sourceMessageID = sourceMessage.messageInfo?.id;
const targetDistanceFromBottom = React.useMemo(() => {
if (!messagesWithHeight) {
return 0;
}
let offset = 0;
for (const message of messagesWithHeight) {
offset += chatMessageItemHeight(message);
if (message.messageInfo && message.messageInfo.id === sourceMessageID) {
return offset;
}
}
return (
messageListVerticalBounds.height + chatMessageItemHeight(sourceMessage)
);
}, [
messageListVerticalBounds.height,
messagesWithHeight,
sourceMessage,
sourceMessageID,
]);
if (!sidebarThreadInfo) {
return {
position: 0,
color: sourceMessage.threadInfo.color,
};
}
const authorNameComponentHeight = sourceMessage.messageInfo.creator.isViewer
? 0
: authorNameHeight;
const currentDistanceFromBottom =
messageListVerticalBounds.height +
messageListVerticalBounds.y -
initialCoordinates.y +
timestampHeight +
authorNameComponentHeight +
currentInputBarHeight;
return {
position:
targetDistanceFromBottom +
targetInputBarHeight -
currentDistanceFromBottom,
color: sidebarThreadInfo.color,
};
}
type AnimatedMessageArgs = {
+sourceMessage: ChatMessageInfoItemWithHeight,
+initialCoordinates: LayoutCoordinates,
+messageListVerticalBounds: VerticalBounds,
+progress: Node,
+targetInputBarHeight: ?number,
};
function useAnimatedMessageTooltipButton({
sourceMessage,
initialCoordinates,
messageListVerticalBounds,
progress,
targetInputBarHeight,
}: AnimatedMessageArgs): {
+style: AnimatedViewStyle,
+threadColorOverride: ?Node,
+isThreadColorDarkOverride: ?boolean,
} {
const chatContext = React.useContext(ChatContext);
invariant(chatContext, 'chatContext should be set');
const {
currentTransitionSidebarSourceID,
setCurrentTransitionSidebarSourceID,
chatInputBarHeights,
sidebarAnimationType,
setSidebarAnimationType,
} = chatContext;
const viewerID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
const sidebarThreadInfo = React.useMemo(() => {
return getSidebarThreadInfo(sourceMessage, viewerID);
}, [sourceMessage, viewerID]);
const currentInputBarHeight =
chatInputBarHeights.get(sourceMessage.threadInfo.id) ?? 0;
const keyboardState = React.useContext(KeyboardContext);
const viewerIsSidebarMember = viewerIsMember(sidebarThreadInfo);
React.useEffect(() => {
const newSidebarAnimationType =
!currentInputBarHeight ||
!targetInputBarHeight ||
keyboardState?.keyboardShowing ||
!viewerIsSidebarMember
? 'fade_source_message'
: 'move_source_message';
setSidebarAnimationType(newSidebarAnimationType);
}, [
currentInputBarHeight,
keyboardState?.keyboardShowing,
setSidebarAnimationType,
sidebarThreadInfo,
targetInputBarHeight,
viewerIsSidebarMember,
]);
const {
position: targetPosition,
color: targetColor,
} = useMessageTargetParameters(
sourceMessage,
initialCoordinates,
messageListVerticalBounds,
currentInputBarHeight,
targetInputBarHeight ?? currentInputBarHeight,
sidebarThreadInfo,
);
React.useEffect(() => {
return () => setCurrentTransitionSidebarSourceID(null);
}, [setCurrentTransitionSidebarSourceID]);
const bottom = React.useMemo(
() =>
interpolateNode(progress, {
inputRange: [0.3, 1],
outputRange: [targetPosition, 0],
extrapolate: Extrapolate.CLAMP,
}),
[progress, targetPosition],
);
const [
isThreadColorDarkOverride,
setThreadColorDarkOverride,
] = React.useState(null);
const setThreadColorBrightness = React.useCallback(() => {
const isSourceThreadDark = colorIsDark(sourceMessage.threadInfo.color);
const isTargetThreadDark = colorIsDark(targetColor);
if (isSourceThreadDark !== isTargetThreadDark) {
setThreadColorDarkOverride(isTargetThreadDark);
}
}, [sourceMessage.threadInfo.color, targetColor]);
const threadColorOverride = React.useMemo(() => {
if (
sourceMessage.messageShapeType !== 'text' ||
!currentTransitionSidebarSourceID
) {
return null;
}
return block([
cond(eq(progress, 1), call([], setThreadColorBrightness)),
interpolateColors(progress, {
inputRange: [0, 1],
outputColorRange: [
`#${targetColor}`,
`#${sourceMessage.threadInfo.color}`,
],
}),
]);
}, [
currentTransitionSidebarSourceID,
progress,
setThreadColorBrightness,
sourceMessage.messageShapeType,
sourceMessage.threadInfo.color,
targetColor,
]);
const messageContainerStyle = React.useMemo(() => {
return {
bottom: currentTransitionSidebarSourceID ? bottom : 0,
opacity:
currentTransitionSidebarSourceID &&
sidebarAnimationType === 'fade_source_message'
? 0
: 1,
};
}, [bottom, currentTransitionSidebarSourceID, sidebarAnimationType]);
return {
style: messageContainerStyle,
threadColorOverride,
isThreadColorDarkOverride,
};
}
function getMessageTooltipKey(item: ChatMessageInfoItemWithHeight): string {
return `tooltip|${messageKey(item.messageInfo)}`;
}
function isMessageTooltipKey(key: string): boolean {
return key.startsWith('tooltip|');
}
function useOverlayPosition(item: ChatMessageInfoItemWithHeight) {
const overlayContext = React.useContext(OverlayContext);
invariant(overlayContext, 'should be set');
for (const overlay of overlayContext.visibleOverlays) {
if (
(overlay.routeName === MultimediaMessageTooltipModalRouteName ||
overlay.routeName === TextMessageTooltipModalRouteName ||
overlay.routeName === RobotextMessageTooltipModalRouteName) &&
overlay.routeKey === getMessageTooltipKey(item)
) {
return overlay.position;
}
}
return undefined;
}
function useContentAndHeaderOpacity(
item: ChatMessageInfoItemWithHeight,
): number | Node {
const overlayPosition = useOverlayPosition(item);
const chatContext = React.useContext(ChatContext);
return React.useMemo(
() =>
overlayPosition &&
chatContext?.sidebarAnimationType === 'move_source_message'
? sub(
1,
interpolateNode(overlayPosition, {
inputRange: [0.05, 0.06],
outputRange: [0, 1],
extrapolate: Extrapolate.CLAMP,
}),
)
: 1,
[chatContext?.sidebarAnimationType, overlayPosition],
);
}
function useDeliveryIconOpacity(
item: ChatMessageInfoItemWithHeight,
): number | Node {
const overlayPosition = useOverlayPosition(item);
const chatContext = React.useContext(ChatContext);
return React.useMemo(() => {
if (
!overlayPosition ||
!chatContext?.currentTransitionSidebarSourceID ||
chatContext?.sidebarAnimationType === 'fade_source_message'
) {
return 1;
}
return interpolateNode(overlayPosition, {
inputRange: [0.05, 0.06, 1],
outputRange: [1, 0, 0],
extrapolate: Extrapolate.CLAMP,
});
}, [
chatContext?.currentTransitionSidebarSourceID,
chatContext?.sidebarAnimationType,
overlayPosition,
]);
}
+function chatMessageItemKey(
+ item: ChatMessageItemWithHeight | ChatMessageItem,
+): string {
+ if (item.itemType === 'loader') {
+ return 'loader';
+ }
+ return messageKey(item.messageInfo);
+}
+
export {
+ chatMessageItemKey,
chatMessageItemHeight,
useAnimatedMessageTooltipButton,
messageItemHeight,
getMessageTooltipKey,
isMessageTooltipKey,
useContentAndHeaderOpacity,
useDeliveryIconOpacity,
};