diff --git a/native/chat/chat-constants.js b/native/chat/chat-constants.js index 46d2e8b6c..132250101 100644 --- a/native/chat/chat-constants.js +++ b/native/chat/chat-constants.js @@ -1,7 +1,26 @@ // @flow -export const inlineSidebarHeight = 20; -export const inlineSidebarMarginTop = 5; -export const inlineSidebarMarginBottom = 3; +export const composedMessageStyle = { + marginLeft: 12, + marginRight: 7, +}; + +export const inlineSidebarStyle = { + height: 38, + marginTop: 5, + marginBottom: 3, +}; +export const inlineSidebarLeftStyle = { + topOffset: -10, +}; + +export const inlineSidebarCenterStyle = { + topOffset: -5, +}; + +export const inlineSidebarRightStyle = { + marginRight: 22, + topOffset: -10, +}; export const clusterEndHeight = 7; diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js index 665b6867e..8e19bce17 100644 --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -1,230 +1,244 @@ // @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, - inlineSidebarMarginBottom, - inlineSidebarMarginTop, + inlineSidebarStyle, + inlineSidebarLeftStyle, + inlineSidebarRightStyle, + composedMessageStyle, } 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; let containerMarginBottom = 5; if (item.endsCluster) { containerMarginBottom += clusterEndHeight; } const containerStyle = [ styles.alignment, { marginBottom: containerMarginBottom }, ]; 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'; + const inlineSidebarPositionStyle = + positioning === 'left' + ? styles.leftInlineSidebar + : styles.rightInlineSidebar; 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, + marginLeft: composedMessageStyle.marginLeft, + marginRight: composedMessageStyle.marginRight, }, content: { alignItems: 'center', flexDirection: 'row-reverse', }, icon: { fontSize: 16, textAlign: 'center', }, iconContainer: { marginLeft: 2, width: 16, }, inlineSidebar: { - marginBottom: inlineSidebarMarginBottom, - marginTop: inlineSidebarMarginTop, + marginBottom: inlineSidebarStyle.marginBottom, + marginTop: inlineSidebarStyle.marginTop, }, leftChatBubble: { justifyContent: 'flex-end', }, + leftInlineSidebar: { + justifyContent: 'flex-start', + position: 'relative', + top: inlineSidebarLeftStyle.topOffset, + }, messageBox: { marginRight: 5, }, rightChatBubble: { justifyContent: 'flex-start', }, + rightInlineSidebar: { + alignSelf: 'flex-end', + position: 'relative', + right: inlineSidebarRightStyle.marginRight, + top: inlineSidebarRightStyle.topOffset, + }, }); 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 eb7a506f5..8e5b9239e 100644 --- a/native/chat/inline-sidebar.react.js +++ b/native/chat/inline-sidebar.react.js @@ -1,99 +1,124 @@ // @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 CommIcon from '../components/comm-icon.react'; +import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react'; import { useStyles } from '../themes/colors'; -import { inlineSidebarHeight } from './chat-constants'; +import { inlineSidebarStyle } from './chat-constants'; import { useNavigateToThread } from './message-list-types'; type Props = { - +threadInfo: ThreadInfo, - +positioning: 'left' | 'center' | 'right', + +threadInfo: ?ThreadInfo, + +reactions?: $ReadOnlyArray, + +disabled?: boolean, }; function InlineSidebar(props: Props): React.Node { - const { threadInfo } = props; - const { sendersText, repliesText } = useInlineSidebarText(threadInfo); + const { disabled = false, reactions, threadInfo } = props; + const { repliesText } = useInlineSidebarText(threadInfo); const navigateToThread = useNavigateToThread(); const onPress = React.useCallback(() => { - navigateToThread({ threadInfo }); - }, [navigateToThread, threadInfo]); + if (threadInfo && !disabled) { + navigateToThread({ threadInfo }); + } + }, [disabled, 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; + const reactionList = React.useMemo(() => { + if (!reactions || reactions.length === 0) { + return null; + } + const reactionItems = reactions.map(reaction => { + return ( + + {reaction} + + ); + }); + return {reactionItems}; + }, [reactions, styles.reaction, styles.reactionsContainer]); + const unreadStyle = threadInfo?.currentUser.unread ? styles.unread : null; + const marginRight = reactionList ? styles.repliesMarginRight : null; + const repliesStyles = React.useMemo( + () => [marginRight, styles.repliesText, unreadStyle], + [marginRight, styles.repliesText, unreadStyle], + ); + const noThreadInfo = !threadInfo; + const sidebarInfo = React.useMemo(() => { + if (noThreadInfo) { + return null; + } + return ( + <> + + {repliesText} + + ); + }, [noThreadInfo, styles.icon, repliesStyles, repliesText]); return ( - - + + + {sidebarInfo} + {reactionList} + ); } const unboundStyles = { - content: { + container: { flexDirection: 'row', - marginRight: 30, - marginLeft: 10, - flex: 1, - height: inlineSidebarHeight, + height: inlineSidebarStyle.height, + display: 'flex', + backgroundColor: 'listBackground', + borderRadius: 16, }, unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, sidebar: { flexDirection: 'row', display: 'flex', alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'inlineSidebarBackground', + padding: 8, + borderRadius: 16, + height: inlineSidebarStyle.height, }, icon: { - color: 'listForegroundTertiaryLabel', + color: 'inlineSidebarLabel', + marginRight: 4, }, - name: { - paddingTop: 1, - color: 'listForegroundTertiaryLabel', - fontSize: 16, - paddingLeft: 4, - paddingRight: 2, + repliesText: { + color: 'inlineSidebarLabel', + fontSize: 14, + lineHeight: 22, }, - leftAlign: { - justifyContent: 'flex-start', + repliesMarginRight: { + marginRight: 12, }, - rightAlign: { - justifyContent: 'flex-end', + reaction: { + marginLeft: 4, + color: 'inlineSidebarLabel', }, - centerAlign: { - justifyContent: 'center', + reactionsContainer: { + display: 'flex', + flexDirection: 'row', + marginLeft: -4, }, }; export default InlineSidebar; diff --git a/native/chat/multimedia-message-utils.js b/native/chat/multimedia-message-utils.js index d11ab1bcd..015a9bd3d 100644 --- a/native/chat/multimedia-message-utils.js +++ b/native/chat/multimedia-message-utils.js @@ -1,146 +1,143 @@ // @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 { - inlineSidebarMarginBottom, - inlineSidebarMarginTop, - inlineSidebarHeight, - clusterEndHeight, -} from './chat-constants'; +import { inlineSidebarStyle, 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; + inlineSidebarStyle.height + + inlineSidebarStyle.marginTop + + inlineSidebarStyle.marginBottom; } 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/robotext-message.react.js b/native/chat/robotext-message.react.js index a8a4b643c..637da2e34 100644 --- a/native/chat/robotext-message.react.js +++ b/native/chat/robotext-message.react.js @@ -1,199 +1,198 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { messageKey } from 'lib/shared/message-utils'; import { useCanCreateSidebarFromMessage } from 'lib/shared/thread-utils'; import { KeyboardContext } from '../keyboard/keyboard-state'; import { OverlayContext } from '../navigation/overlay-context'; import { RobotextMessageTooltipModalRouteName } from '../navigation/route-names'; import type { NavigationRoute } from '../navigation/route-names'; import { useStyles } from '../themes/colors'; import type { ChatRobotextMessageInfoItemWithHeight } from '../types/chat-types'; import type { VerticalBounds } from '../types/layout-types'; import { AnimatedView } from '../types/styles'; +import { inlineSidebarCenterStyle } from './chat-constants'; import type { ChatNavigationProp } from './chat.react'; import InlineSidebar from './inline-sidebar.react'; import { InnerRobotextMessage } from './inner-robotext-message.react'; import { robotextMessageTooltipHeight } from './robotext-message-tooltip-modal.react'; import { Timestamp } from './timestamp.react'; import { getMessageTooltipKey, useContentAndHeaderOpacity } from './utils'; type Props = { ...React.ElementConfig, +item: ChatRobotextMessageInfoItemWithHeight, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, }; function RobotextMessage(props: Props): React.Node { const { item, navigation, route, focused, toggleFocus, verticalBounds, ...viewProps } = props; let timestamp = null; if (focused || item.startsConversation) { timestamp = ( ); } const styles = useStyles(unboundStyles); let inlineSidebar = null; if (item.threadCreatedFromMessage) { inlineSidebar = ( - + ); } const keyboardState = React.useContext(KeyboardContext); const key = messageKey(item.messageInfo); const onPress = React.useCallback(() => { const didDismiss = keyboardState && keyboardState.dismissKeyboardIfShowing(); if (!didDismiss) { toggleFocus(key); } }, [keyboardState, toggleFocus, key]); const overlayContext = React.useContext(OverlayContext); const viewRef = React.useRef>(); const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( item.threadInfo, item.messageInfo, ); const visibleEntryIDs = React.useMemo(() => { if (item.threadCreatedFromMessage) { return ['open_sidebar']; } else if (canCreateSidebarFromMessage) { return ['create_sidebar']; } return []; }, [item.threadCreatedFromMessage, canCreateSidebarFromMessage]); const openRobotextTooltipModal = React.useCallback( (x, y, width, height, pageX, pageY) => { invariant( verticalBounds, 'verticalBounds should be present in openRobotextTooltipModal', ); const coordinates = { x: pageX, y: pageY, width, height }; const messageTop = pageY; const messageBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = robotextMessageTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = robotextMessageTooltipHeight + aboveMargin; let location = 'below', margin = 0; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { location = 'above'; margin = aboveMargin; } props.navigation.navigate({ name: RobotextMessageTooltipModalRouteName, params: { presentedFrom: props.route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs, location, margin, item, }, key: getMessageTooltipKey(item), }); }, [item, props.navigation, props.route.key, verticalBounds, visibleEntryIDs], ); const onLongPress = React.useCallback(() => { if (keyboardState && keyboardState.dismissKeyboardIfShowing()) { return; } if (visibleEntryIDs.length === 0) { return; } if (!viewRef.current || !verticalBounds) { return; } if (!focused) { toggleFocus(messageKey(item.messageInfo)); } invariant(overlayContext, 'RobotextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); viewRef.current?.measure(openRobotextTooltipModal); }, [ focused, item, keyboardState, overlayContext, toggleFocus, verticalBounds, viewRef, visibleEntryIDs, openRobotextTooltipModal, ]); const onLayout = React.useCallback(() => {}, []); const contentAndHeaderOpacity = useContentAndHeaderOpacity(item); return ( {timestamp} {inlineSidebar} ); } const unboundStyles = { sidebar: { - marginTop: -5, - marginBottom: 5, + marginTop: inlineSidebarCenterStyle.topOffset, + marginBottom: -inlineSidebarCenterStyle.topOffset, + alignSelf: 'center', }, }; export { RobotextMessage }; diff --git a/native/chat/utils.js b/native/chat/utils.js index 745b88620..82fc4e28f 100644 --- a/native/chat/utils.js +++ b/native/chat/utils.js @@ -1,423 +1,420 @@ // @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 { - clusterEndHeight, - inlineSidebarHeight, - inlineSidebarMarginBottom, - inlineSidebarMarginTop, -} from './chat-constants'; +import { clusterEndHeight, inlineSidebarStyle } 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; + inlineSidebarStyle.height + + inlineSidebarStyle.marginTop + + inlineSidebarStyle.marginBottom; } return height; } function robotextMessageItemHeight( item: ChatRobotextMessageInfoItemWithHeight, ): number { if (item.threadCreatedFromMessage) { - return item.contentHeight + inlineSidebarHeight; + return item.contentHeight + inlineSidebarStyle.height; } 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>(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, }; diff --git a/native/themes/colors.js b/native/themes/colors.js index ad61a1a1a..efbc626e7 100644 --- a/native/themes/colors.js +++ b/native/themes/colors.js @@ -1,284 +1,288 @@ // @flow import * as React from 'react'; import { StyleSheet } from 'react-native'; import { createSelector } from 'reselect'; import { selectBackgroundIsDark } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { useSelector } from '../redux/redux-utils'; import type { AppState } from '../redux/state-types'; import type { GlobalTheme } from '../types/themes'; const light = Object.freeze({ blockQuoteBackground: '#D3D3D3', blockQuoteBorder: '#C0C0C0', codeBackground: '#DCDCDC', disconnectedBarBackground: '#FFFFFF', editButton: '#A4A4A2', floatingButtonBackground: '#999999', floatingButtonLabel: '#EEEEEE', greenButton: '#6EC472', greenText: 'green', headerChevron: '#0A0A0A', + inlineSidebarBackground: '#E0E0E0', + inlineSidebarLabel: '#000000', link: '#036AFF', listBackground: 'white', listBackgroundLabel: 'black', listBackgroundSecondaryLabel: '#444444', listBackgroundTernaryLabel: '#999999', listChatBubble: '#DDDDDDBB', listForegroundLabel: 'black', listForegroundQuaternaryLabel: '#AAAAAA', listForegroundSecondaryLabel: '#333333', listForegroundTertiaryLabel: '#666666', listInputBackground: '#DDDDDD', listInputBar: '#E2E2E2', listInputBorder: '#AAAAAAAA', listInputButton: '#888888', listIosHighlightUnderlay: '#DDDDDDDD', listSearchBackground: '#E2E2E2', listSearchIcon: '#AAAAAA', listSeparator: '#EEEEEE', listSeparatorLabel: '#555555', markdownLink: '#000000', mintButton: '#44CC99', modalBackground: '#EEEEEE', modalBackgroundLabel: '#333333', modalBackgroundSecondaryLabel: '#AAAAAA', modalButton: '#BBBBBB', modalButtonLabel: 'black', modalContrastBackground: 'black', modalContrastForegroundLabel: 'white', modalContrastOpacity: 0.7, modalForeground: 'white', modalForegroundBorder: '#CCCCCC', modalForegroundLabel: 'black', modalForegroundSecondaryLabel: '#888888', modalForegroundTertiaryLabel: '#AAAAAA', modalIosHighlightUnderlay: '#CCCCCCDD', modalSubtext: '#CCCCCC', modalSubtextLabel: '#555555', navigationCard: '#FFFFFF', navigationChevron: '#A4A4A2', panelBackground: '#E9E9EF', panelBackgroundLabel: '#888888', panelForeground: 'white', panelForegroundBorder: '#CCCCCC', panelForegroundLabel: 'black', panelForegroundSecondaryLabel: '#333333', panelForegroundTertiaryLabel: '#888888', panelIosHighlightUnderlay: '#EEEEEEDD', panelSecondaryForeground: '#F5F5F5', panelSecondaryForegroundBorder: '#D1D1D6', redButton: '#BB8888', redText: '#AA0000', tabBarAccent: '#AE94DB', tabBarBackground: '#F5F5F5', vibrantGreenButton: '#00C853', vibrantRedButton: '#F53100', }); export type Colors = $Exact; const dark: Colors = Object.freeze({ blockQuoteBackground: '#A9A9A9', blockQuoteBorder: '#808080', codeBackground: '#0A0A0A', disconnectedBarBackground: '#1D1D1D', editButton: '#5B5B5D', floatingButtonBackground: '#666666', floatingButtonLabel: 'white', greenButton: '#43A047', greenText: '#44FF44', headerChevron: '#FFFFFF', + inlineSidebarBackground: '#666666', + inlineSidebarLabel: '#FFFFFF', link: '#129AFF', listBackground: '#0A0A0A', listBackgroundLabel: '#C7C7CC', listBackgroundSecondaryLabel: '#BBBBBB', listBackgroundTernaryLabel: '#888888', listChatBubble: '#26252A', listForegroundLabel: 'white', listForegroundQuaternaryLabel: '#555555', listForegroundSecondaryLabel: '#CCCCCC', listForegroundTertiaryLabel: '#999999', listInputBackground: '#1D1D1D', listInputBar: '#555555', listInputBorder: '#333333', listInputButton: '#AAAAAA', listIosHighlightUnderlay: '#BBBBBB88', listSearchBackground: '#1D1D1D', listSearchIcon: '#AAAAAA', listSeparator: '#3A3A3C', listSeparatorLabel: '#EEEEEE', markdownLink: '#FFFFFF', mintButton: '#44CC99', modalBackground: '#0A0A0A', modalBackgroundLabel: '#CCCCCC', modalBackgroundSecondaryLabel: '#555555', modalButton: '#666666', modalButtonLabel: 'white', modalContrastBackground: 'white', modalContrastForegroundLabel: 'black', modalContrastOpacity: 0.85, modalForeground: '#1C1C1E', modalForegroundBorder: '#1C1C1E', modalForegroundLabel: 'white', modalForegroundSecondaryLabel: '#AAAAAA', modalForegroundTertiaryLabel: '#666666', modalIosHighlightUnderlay: '#AAAAAA88', modalSubtext: '#444444', modalSubtextLabel: '#AAAAAA', navigationCard: '#2A2A2A', navigationChevron: '#5B5B5D', panelBackground: '#0A0A0A', panelBackgroundLabel: '#C7C7CC', panelForeground: '#1D1D1D', panelForegroundBorder: '#2C2C2E', panelForegroundLabel: 'white', panelForegroundSecondaryLabel: '#CCCCCC', panelForegroundTertiaryLabel: '#AAAAAA', panelIosHighlightUnderlay: '#313035', panelSecondaryForeground: '#333333', panelSecondaryForegroundBorder: '#666666', redButton: '#FF4444', redText: '#FF4444', tabBarAccent: '#AE94DB', tabBarBackground: '#0A0A0A', vibrantGreenButton: '#00C853', vibrantRedButton: '#F53100', }); const colors = { light, dark }; const colorsSelector: (state: AppState) => Colors = createSelector( (state: AppState) => state.globalThemeInfo.activeTheme, (theme: ?GlobalTheme) => { const explicitTheme = theme ? theme : 'light'; return colors[explicitTheme]; }, ); const magicStrings = new Set(); for (const theme in colors) { for (const magicString in colors[theme]) { magicStrings.add(magicString); } } type Styles = { [name: string]: { [field: string]: mixed } }; type ReplaceField = (input: any) => any; export type StyleSheetOf = $ObjMap; function stylesFromColors( obj: IS, themeColors: Colors, ): StyleSheetOf { const result = {}; for (const key in obj) { const style = obj[key]; const filledInStyle = { ...style }; for (const styleKey in style) { const styleValue = style[styleKey]; if (typeof styleValue !== 'string') { continue; } if (magicStrings.has(styleValue)) { const mapped = themeColors[styleValue]; if (mapped) { filledInStyle[styleKey] = mapped; } } } result[key] = filledInStyle; } return StyleSheet.create(result); } function styleSelector( obj: IS, ): (state: AppState) => StyleSheetOf { return createSelector(colorsSelector, (themeColors: Colors) => stylesFromColors(obj, themeColors), ); } function useStyles(obj: IS): StyleSheetOf { const ourColors = useColors(); return React.useMemo(() => stylesFromColors(obj, ourColors), [ obj, ourColors, ]); } function useOverlayStyles(obj: IS): StyleSheetOf { const navContext = React.useContext(NavContext); const navigationState = navContext && navContext.state; const theme = useSelector( (state: AppState) => state.globalThemeInfo.activeTheme, ); const backgroundIsDark = React.useMemo( () => selectBackgroundIsDark(navigationState, theme), [navigationState, theme], ); const syntheticTheme = backgroundIsDark ? 'dark' : 'light'; return React.useMemo(() => stylesFromColors(obj, colors[syntheticTheme]), [ obj, syntheticTheme, ]); } function useColors(): Colors { return useSelector(colorsSelector); } function getStylesForTheme( obj: IS, theme: GlobalTheme, ): StyleSheetOf { return stylesFromColors(obj, colors[theme]); } export type IndicatorStyle = 'white' | 'black'; function useIndicatorStyle(): IndicatorStyle { const theme = useSelector( (state: AppState) => state.globalThemeInfo.activeTheme, ); return theme && theme === 'dark' ? 'white' : 'black'; } const indicatorStyleSelector: ( state: AppState, ) => IndicatorStyle = createSelector( (state: AppState) => state.globalThemeInfo.activeTheme, (theme: ?GlobalTheme) => { return theme && theme === 'dark' ? 'white' : 'black'; }, ); export type KeyboardAppearance = 'default' | 'light' | 'dark'; const keyboardAppearanceSelector: ( state: AppState, ) => KeyboardAppearance = createSelector( (state: AppState) => state.globalThemeInfo.activeTheme, (theme: ?GlobalTheme) => { return theme && theme === 'dark' ? 'dark' : 'light'; }, ); function useKeyboardAppearance(): KeyboardAppearance { return useSelector(keyboardAppearanceSelector); } export { colors, colorsSelector, styleSelector, useStyles, useOverlayStyles, useColors, getStylesForTheme, useIndicatorStyle, indicatorStyleSelector, useKeyboardAppearance, };