diff --git a/native/chat/chat-constants.js b/native/chat/chat-constants.js index 49135d1da..53f834c6f 100644 --- a/native/chat/chat-constants.js +++ b/native/chat/chat-constants.js @@ -1,26 +1,28 @@ // @flow export const composedMessageStyle = { marginLeft: 12, marginRight: 7, }; export const inlineEngagementStyle = { height: 38, marginTop: 5, marginBottom: 3, }; export const inlineEngagementLeftStyle = { topOffset: -10, }; export const inlineEngagementCenterStyle = { topOffset: -5, }; export const inlineEngagementRightStyle = { marginRight: 22, topOffset: -10, }; export const clusterEndHeight = 7; + +export const avatarOffset = 32; diff --git a/native/chat/composed-message-width.js b/native/chat/composed-message-width.js index 03ce1fc05..b2884010d 100644 --- a/native/chat/composed-message-width.js +++ b/native/chat/composed-message-width.js @@ -1,18 +1,26 @@ // @flow +import { avatarOffset } from './chat-constants.js'; import { useSelector } from '../redux/redux-utils.js'; +import { useShouldRenderAvatars } from '../utils/avatar-utils.js'; function useMessageListScreenWidth(): number { return useSelector(state => { const { dimensions } = state; return dimensions.rotated ? dimensions.height : dimensions.width; }); } // Keep sorta synced with styles.alignment/styles.messageBox in ComposedMessage function useComposedMessageMaxWidth(): number { const messageListScreenWidth = useMessageListScreenWidth(); + const shouldRenderAvatars = useShouldRenderAvatars(); + + if (shouldRenderAvatars) { + return (messageListScreenWidth - 24 - avatarOffset) * 0.8; + } + return (messageListScreenWidth - 24) * 0.8; } export { useMessageListScreenWidth, useComposedMessageMaxWidth }; diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js index a0883c443..ddc7ff885 100644 --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -1,249 +1,299 @@ // @flow import Icon from '@expo/vector-icons/Feather.js'; import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import Animated from 'react-native-reanimated'; +import { getAvatarForUser } from 'lib/shared/avatar-utils.js'; import { createMessageReply } from 'lib/shared/message-utils.js'; import { assertComposableMessageType } from 'lib/types/message-types.js'; import { clusterEndHeight, inlineEngagementStyle, inlineEngagementLeftStyle, inlineEngagementRightStyle, composedMessageStyle, + avatarOffset, } from './chat-constants.js'; import { useComposedMessageMaxWidth } from './composed-message-width.js'; import { FailedSend } from './failed-send.react.js'; import { InlineEngagement } from './inline-engagement.react.js'; import { MessageHeader } from './message-header.react.js'; import { useNavigateToSidebar } from './sidebar-navigation.js'; import SwipeableMessage from './swipeable-message.react.js'; import { useContentAndHeaderOpacity, useDeliveryIconOpacity } from './utils.js'; +import Avatar from '../components/avatar.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import { type Colors, useColors } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; import { type AnimatedStyleObj, AnimatedView } from '../types/styles.js'; +import { useShouldRenderAvatars } from '../utils/avatar-utils.js'; /* 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: () => mixed, + +shouldRenderAvatars: boolean, }; class ComposedMessage extends React.PureComponent { render() { assertComposableMessageType(this.props.item.messageInfo.type); const { item, sendFailed, focused, swipeOptions, children, composedMessageMaxWidth, colors, inputState, navigateToSidebar, contentAndHeaderOpacity, deliveryIconOpacity, + shouldRenderAvatars, ...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 }; + const swipeableMessageBoxStyle = [ + styles.swipeableContainer, + { maxWidth: composedMessageMaxWidth }, + ]; + + const messageBoxStyleContainerStyle = [styles.messageBoxContainer]; + const positioningStyle = isViewer + ? { alignItems: 'flex-end' } + : { alignItems: 'flex-start' }; + messageBoxStyleContainerStyle.push(positioningStyle); 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; + + let avatar; + if (!isViewer && item.endsCluster && shouldRenderAvatars) { + const avatarInfo = getAvatarForUser(item.messageInfo.creator); + avatar = ( + + + + ); + } else if (!isViewer && shouldRenderAvatars) { + avatar = ; + } + const messageBox = ( - + + {avatar} {children} ); let inlineEngagement = null; if ( item.threadCreatedFromMessage || Object.keys(item.reactions).length > 0 ) { const positioning = isViewer ? 'right' : 'left'; - const inlineEngagementPositionStyle = - positioning === 'left' - ? styles.leftInlineEngagement - : styles.rightInlineEngagement; + + const inlineEngagementPositionStyle = []; + if (positioning === 'left') { + inlineEngagementPositionStyle.push(styles.leftInlineEngagement); + } else { + inlineEngagementPositionStyle.push(styles.rightInlineEngagement); + } + if (this.props.shouldRenderAvatars) { + inlineEngagementPositionStyle.push({ marginLeft: avatarOffset }); + } + inlineEngagement = ( ); } return ( {deliveryIcon} {messageBox} {failedSendInfo} {inlineEngagement} ); } 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: composedMessageStyle.marginLeft, marginRight: composedMessageStyle.marginRight, }, + avatarContainer: { + marginRight: 8, + }, + avatarOffset: { + width: avatarOffset, + }, content: { alignItems: 'center', flexDirection: 'row-reverse', }, icon: { fontSize: 16, textAlign: 'center', }, iconContainer: { marginLeft: 2, width: 16, }, inlineEngagement: { marginBottom: inlineEngagementStyle.marginBottom, marginTop: inlineEngagementStyle.marginTop, }, leftChatBubble: { justifyContent: 'flex-end', }, leftInlineEngagement: { justifyContent: 'flex-start', position: 'relative', top: inlineEngagementLeftStyle.topOffset, }, - messageBox: { + messageBoxContainer: { + flex: 1, marginRight: 5, }, rightChatBubble: { justifyContent: 'flex-start', }, rightInlineEngagement: { alignSelf: 'flex-end', position: 'relative', right: inlineEngagementRightStyle.marginRight, top: inlineEngagementRightStyle.topOffset, }, + swipeableContainer: { + alignItems: 'flex-end', + flexDirection: 'row', + }, }); 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); + const shouldRenderAvatars = useShouldRenderAvatars(); + return ( ); }); export default ConnectedComposedMessage; diff --git a/native/chat/message-header.react.js b/native/chat/message-header.react.js index 50deb8731..126c26d4a 100644 --- a/native/chat/message-header.react.js +++ b/native/chat/message-header.react.js @@ -1,83 +1,92 @@ // @flow import * as React from 'react'; import { View } from 'react-native'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; -import { clusterEndHeight } from './chat-constants.js'; +import { clusterEndHeight, avatarOffset } from './chat-constants.js'; import type { DisplayType } from './timestamp.react.js'; import { Timestamp, timestampHeight } from './timestamp.react.js'; import { SingleLine } from '../components/single-line.react.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; +import { useShouldRenderAvatars } from '../utils/avatar-utils.js'; 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'; const shouldShowUsername = !isViewer && (modalDisplay || item.startsCluster); const stringForUser = useStringForUser(shouldShowUsername ? creator : null); + const shouldRenderAvatars = useShouldRenderAvatars(); + let authorName = null; if (stringForUser) { const style = [styles.authorName]; if (modalDisplay) { style.push(styles.modal); } + + if (shouldRenderAvatars) { + style.push({ marginLeft: 12 + avatarOffset }); + } else { + style.push({ marginLeft: 12 }); + } + authorName = {stringForUser}; } 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/message-tooltip-button-avatar.react.js b/native/chat/message-tooltip-button-avatar.react.js new file mode 100644 index 000000000..4e3f71254 --- /dev/null +++ b/native/chat/message-tooltip-button-avatar.react.js @@ -0,0 +1,48 @@ +// @flow + +import * as React from 'react'; +import { View, StyleSheet } from 'react-native'; + +import { getAvatarForUser } from 'lib/shared/avatar-utils.js'; + +import { avatarOffset } from './chat-constants.js'; +import Avatar from '../components/avatar.react.js'; +import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; +import { useShouldRenderAvatars } from '../utils/avatar-utils.js'; + +type Props = { + +item: ChatMessageInfoItemWithHeight, +}; + +function MessageTooltipButtonAvatar(props: Props): React.Node { + const { item } = props; + + const avatarInfo = React.useMemo( + () => getAvatarForUser(item.messageInfo.creator), + [item.messageInfo.creator], + ); + + const shouldRenderAvatars = useShouldRenderAvatars(); + + if (item.messageInfo.creator.isViewer || !shouldRenderAvatars) { + return null; + } + return ( + + + + ); +} + +const styles = StyleSheet.create({ + avatarContainer: { + bottom: 0, + left: -avatarOffset, + position: 'absolute', + }, +}); + +const MemoizedMessageTooltipButtonAvatar: React.ComponentType = + React.memo(MessageTooltipButtonAvatar); + +export default MemoizedMessageTooltipButtonAvatar; diff --git a/native/chat/multimedia-message-tooltip-button.react.js b/native/chat/multimedia-message-tooltip-button.react.js index 8a963916d..f1f22d92d 100644 --- a/native/chat/multimedia-message-tooltip-button.react.js +++ b/native/chat/multimedia-message-tooltip-button.react.js @@ -1,177 +1,179 @@ // @flow import * as React from 'react'; import Animated from 'react-native-reanimated'; import EmojiPicker from 'rn-emoji-keyboard'; import { localIDPrefix } from 'lib/shared/message-utils.js'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js'; import { TooltipInlineEngagement } from './inline-engagement.react.js'; import { InnerMultimediaMessage } from './inner-multimedia-message.react.js'; import { MessageHeader } from './message-header.react.js'; +import MessageTooltipButtonAvatar from './message-tooltip-button-avatar.react.js'; import { useSendReaction } from './reaction-message-utils.js'; import ReactionSelectionPopover from './reaction-selection-popover.react.js'; import SidebarInputBarHeightMeasurer from './sidebar-input-bar-height-measurer.react.js'; import { useAnimatedMessageTooltipButton } from './utils.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useTooltipActions } from '../tooltip/tooltip-hooks.js'; import type { TooltipRoute } from '../tooltip/tooltip.react.js'; /* eslint-disable import/no-named-as-default-member */ const { Node, Extrapolate, interpolateNode } = Animated; /* eslint-enable import/no-named-as-default-member */ function noop() {} type Props = { +navigation: AppNavigationProp<'MultimediaMessageTooltipModal'>, +route: TooltipRoute<'MultimediaMessageTooltipModal'>, +progress: Node, +isOpeningSidebar: boolean, }; function MultimediaMessageTooltipButton(props: Props): React.Node { const { navigation, route, progress, isOpeningSidebar } = props; const windowWidth = useSelector(state => state.dimensions.width); const [sidebarInputBarHeight, setSidebarInputBarHeight] = React.useState(null); const onInputBarMeasured = React.useCallback((height: number) => { setSidebarInputBarHeight(height); }, []); const { item, verticalBounds, initialCoordinates } = route.params; const { style: messageContainerStyle } = useAnimatedMessageTooltipButton({ sourceMessage: item, initialCoordinates, messageListVerticalBounds: verticalBounds, progress, targetInputBarHeight: sidebarInputBarHeight, }); const headerStyle = React.useMemo(() => { const bottom = initialCoordinates.height; const opacity = interpolateNode(progress, { inputRange: [0, 0.05], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); return { opacity, position: 'absolute', left: -initialCoordinates.x, width: windowWidth, bottom, }; }, [initialCoordinates.height, initialCoordinates.x, progress, windowWidth]); const inlineEngagement = React.useMemo(() => { if (!item.threadCreatedFromMessage) { return null; } return ( ); }, [initialCoordinates, isOpeningSidebar, item, progress, windowWidth]); const innerMultimediaMessage = React.useMemo( () => ( ), [item, navigation.goBackOnce, verticalBounds], ); const { messageInfo, threadInfo, reactions } = item; const nextLocalID = useSelector(state => state.nextLocalID); const localID = `${localIDPrefix}${nextLocalID}`; const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); const sendReaction = useSendReaction( messageInfo.id, localID, threadInfo.id, reactions, ); const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false); const openEmojiPicker = React.useCallback(() => { setEmojiPickerOpen(true); }, []); const reactionSelectionPopover = React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } return ( ); }, [ navigation, route, openEmojiPicker, canCreateReactionFromMessage, sendReaction, ]); const tooltipRouteKey = route.key; const { dismissTooltip } = useTooltipActions(navigation, tooltipRouteKey); const onEmojiSelected = React.useCallback( emoji => { sendReaction(emoji.emoji); dismissTooltip(); }, [sendReaction, dismissTooltip], ); return ( <> + {reactionSelectionPopover} {innerMultimediaMessage} {inlineEngagement} ); } export default MultimediaMessageTooltipButton; diff --git a/native/chat/text-message-tooltip-button.react.js b/native/chat/text-message-tooltip-button.react.js index 719537cf2..a4976fc09 100644 --- a/native/chat/text-message-tooltip-button.react.js +++ b/native/chat/text-message-tooltip-button.react.js @@ -1,183 +1,185 @@ // @flow import * as React from 'react'; import Animated from 'react-native-reanimated'; import EmojiPicker from 'rn-emoji-keyboard'; import { localIDPrefix } from 'lib/shared/message-utils.js'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js'; import { TooltipInlineEngagement } from './inline-engagement.react.js'; import { InnerTextMessage } from './inner-text-message.react.js'; import { MessageHeader } from './message-header.react.js'; import { MessageListContextProvider } from './message-list-types.js'; import { MessagePressResponderContext } from './message-press-responder-context.js'; +import MessageTooltipButtonAvatar from './message-tooltip-button-avatar.react.js'; import { useSendReaction } from './reaction-message-utils.js'; import ReactionSelectionPopover from './reaction-selection-popover.react.js'; import SidebarInputBarHeightMeasurer from './sidebar-input-bar-height-measurer.react.js'; import { useAnimatedMessageTooltipButton } from './utils.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useTooltipActions } from '../tooltip/tooltip-hooks.js'; import type { TooltipRoute } from '../tooltip/tooltip.react.js'; /* eslint-disable import/no-named-as-default-member */ const { Node, interpolateNode, Extrapolate } = Animated; /* eslint-enable import/no-named-as-default-member */ type Props = { +navigation: AppNavigationProp<'TextMessageTooltipModal'>, +route: TooltipRoute<'TextMessageTooltipModal'>, +progress: Node, +isOpeningSidebar: boolean, }; function TextMessageTooltipButton(props: Props): React.Node { const { navigation, route, progress, isOpeningSidebar } = props; const windowWidth = useSelector(state => state.dimensions.width); const [sidebarInputBarHeight, setSidebarInputBarHeight] = React.useState(null); const onInputBarMeasured = React.useCallback((height: number) => { setSidebarInputBarHeight(height); }, []); const { item, verticalBounds, initialCoordinates } = route.params; const { style: messageContainerStyle, threadColorOverride, isThreadColorDarkOverride, } = useAnimatedMessageTooltipButton({ sourceMessage: item, initialCoordinates, messageListVerticalBounds: verticalBounds, progress, targetInputBarHeight: sidebarInputBarHeight, }); const headerStyle = React.useMemo(() => { const bottom = initialCoordinates.height; const opacity = interpolateNode(progress, { inputRange: [0, 0.05], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); return { opacity, position: 'absolute', left: -initialCoordinates.x, width: windowWidth, bottom, }; }, [initialCoordinates.height, initialCoordinates.x, progress, windowWidth]); const messagePressResponderContext = React.useMemo( () => ({ onPressMessage: navigation.goBackOnce, }), [navigation.goBackOnce], ); const inlineEngagement = React.useMemo(() => { if (!item.threadCreatedFromMessage) { return null; } return ( ); }, [initialCoordinates, isOpeningSidebar, item, progress, windowWidth]); const { messageInfo, threadInfo, reactions } = item; const nextLocalID = useSelector(state => state.nextLocalID); const localID = `${localIDPrefix}${nextLocalID}`; const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); const sendReaction = useSendReaction( messageInfo.id, localID, threadInfo.id, reactions, ); const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false); const openEmojiPicker = React.useCallback(() => { setEmojiPickerOpen(true); }, []); const reactionSelectionPopover = React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } return ( ); }, [ navigation, route, openEmojiPicker, canCreateReactionFromMessage, sendReaction, ]); const tooltipRouteKey = route.key; const { dismissTooltip } = useTooltipActions(navigation, tooltipRouteKey); const onEmojiSelected = React.useCallback( emoji => { sendReaction(emoji.emoji); dismissTooltip(); }, [sendReaction, dismissTooltip], ); return ( + {reactionSelectionPopover} {inlineEngagement} ); } export default TextMessageTooltipButton;