diff --git a/lib/shared/chat-message-item-utils.js b/lib/shared/chat-message-item-utils.js index 9b839e816..bd1c58e88 100644 --- a/lib/shared/chat-message-item-utils.js +++ b/lib/shared/chat-message-item-utils.js @@ -1,56 +1,65 @@ // @flow import { messageKey } from './message-utils.js'; import type { ReactionInfo } from '../selectors/chat-selectors.js'; import { getMessageLabel } from '../shared/edit-messages-utils.js'; import type { RobotextMessageInfo, ComposableMessageInfo, } from '../types/message-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; +import { longAbsoluteDate } from '../utils/date-utils.js'; type ChatMessageItemMessageInfo = ComposableMessageInfo | RobotextMessageInfo; // This complicated type matches both ChatMessageItem and // ChatMessageItemWithHeight, and is a disjoint union of types type BaseChatMessageInfoItem = { +itemType: 'message', +messageInfo: ChatMessageItemMessageInfo, +messageInfos?: ?void, ... }; type BaseChatMessageItem = | BaseChatMessageInfoItem | { +itemType: 'loader', +messageInfo?: ?void, +messageInfos?: ?void, ... }; function chatMessageItemKey(item: BaseChatMessageItem): string { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } +function chatMessageInfoItemTimestamp(item: BaseChatMessageInfoItem): string { + return longAbsoluteDate(item.messageInfo.time); +} + type BaseChatMessageItemForEngagementCheck = { +threadCreatedFromMessage: ?ThreadInfo, +reactions: ReactionInfo, +hasBeenEdited?: ?boolean, ... }; function chatMessageItemHasEngagement( item: BaseChatMessageItemForEngagementCheck, threadID: string, ): boolean { const label = getMessageLabel(item.hasBeenEdited, threadID); return ( !!label || !!item.threadCreatedFromMessage || Object.keys(item.reactions).length > 0 ); } -export { chatMessageItemKey, chatMessageItemHasEngagement }; +export { + chatMessageItemKey, + chatMessageInfoItemTimestamp, + chatMessageItemHasEngagement, +}; diff --git a/native/chat/message-header.react.js b/native/chat/message-header.react.js index e85f274f6..f8c73f6b6 100644 --- a/native/chat/message-header.react.js +++ b/native/chat/message-header.react.js @@ -1,148 +1,142 @@ // @flow import { useRoute } from '@react-navigation/native'; import * as React from 'react'; import { View, TouchableOpacity } from 'react-native'; import { useStringForUser } from 'lib/hooks/ens-cache.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 { MessageListRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import type { ChatComposedMessageInfoItemWithHeight } from '../types/chat-types.js'; import { useNavigateToUserProfileBottomSheet } from '../user-profile/user-profile-utils.js'; type Props = { +item: ChatComposedMessageInfoItemWithHeight, +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 { creator } = item.messageInfo; const { isViewer } = creator; const route = useRoute(); const modalDisplay = display === 'modal'; const shouldShowUsername = !isViewer && (modalDisplay || item.startsCluster); const stringForUser = useStringForUser(shouldShowUsername ? creator : null); const navigateToUserProfileBottomSheet = useNavigateToUserProfileBottomSheet(); const onPressAuthorName = React.useCallback( () => navigateToUserProfileBottomSheet(item.messageInfo.creator.id), [item.messageInfo.creator.id, navigateToUserProfileBottomSheet], ); const authorNameStyle = React.useMemo(() => { const style = [styles.authorName]; if (modalDisplay) { style.push(styles.modal); } return style; }, [modalDisplay, styles.authorName, styles.modal]); const authorName = React.useMemo(() => { if (!stringForUser) { return null; } return ( {stringForUser} ); }, [ authorNameStyle, onPressAuthorName, stringForUser, styles.authorNameContainer, ]); // We only want to render the top-placed timestamp for a message if it's // rendered in the message list, and not any separate screens (i.e. // the PinnedMessagesScreen). const presentedFromMessageList = typeof route.params?.presentedFrom === 'string' && route.params.presentedFrom.startsWith(MessageListRouteName); const messageInMessageList = route.name === MessageListRouteName || presentedFromMessageList; const timestamp = React.useMemo(() => { if (!messageInMessageList || (!modalDisplay && !item.startsConversation)) { return null; } - return ; - }, [ - display, - item.startsConversation, - messageInMessageList, - modalDisplay, - time, - ]); + return ; + }, [display, item, messageInMessageList, modalDisplay]); const containerStyle = React.useMemo(() => { if (!focused || modalDisplay) { return null; } let topMargin = 0; if (!item.startsCluster && !item.messageInfo.creator.isViewer) { topMargin += authorNameHeight + clusterEndHeight; } if (!item.startsConversation) { topMargin += timestampHeight; } return { marginTop: topMargin }; }, [ focused, item.messageInfo.creator.isViewer, item.startsCluster, item.startsConversation, modalDisplay, ]); return ( {timestamp} {authorName} ); } const authorNameHeight = 25; const unboundStyles = { authorNameContainer: { bottom: 0, marginRight: 7, marginLeft: 12 + avatarOffset, alignSelf: 'baseline', }, authorName: { color: 'listBackgroundSecondaryLabel', fontSize: 14, height: authorNameHeight, paddingHorizontal: 12, paddingVertical: 4, }, modal: { // high contrast framed against OverlayNavigator-dimmed background color: 'white', }, }; export { MessageHeader, authorNameHeight }; diff --git a/native/chat/message-result.react.js b/native/chat/message-result.react.js index 57a96b4d8..54eff5ee4 100644 --- a/native/chat/message-result.react.js +++ b/native/chat/message-result.react.js @@ -1,95 +1,95 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; +import { chatMessageInfoItemTimestamp } from 'lib/shared/chat-message-item-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import { longAbsoluteDate } from 'lib/utils/date-utils.js'; import { type ChatNavigationProp } from './chat.react.js'; import { MessageListContextProvider } from './message-list-types.js'; import Message from './message.react.js'; import { modifyItemForResultScreen } from './utils.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import type { NavigationRoute } from '../navigation/route-names'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; type MessageResultProps = { +item: ChatMessageInfoItemWithHeight, +threadInfo: ThreadInfo, +navigation: | AppNavigationProp<'TogglePinModal'> | ChatNavigationProp<'PinnedMessagesScreen'> | ChatNavigationProp<'MessageSearch'>, +route: | NavigationRoute<'TogglePinModal'> | NavigationRoute<'PinnedMessagesScreen'> | NavigationRoute<'MessageSearch'>, +messageVerticalBounds: ?VerticalBounds, +scrollable: boolean, }; function MessageResult(props: MessageResultProps): React.Node { const styles = useStyles(unboundStyles); const onToggleFocus = React.useCallback(() => {}, []); const item = React.useMemo( () => modifyItemForResultScreen(props.item), [props.item], ); const containerStyle = React.useMemo( () => props.scrollable ? [styles.container, styles.containerOverflow] : styles.container, [props.scrollable, styles.container, styles.containerOverflow], ); return ( - {longAbsoluteDate(props.item.messageInfo.time)} + {chatMessageInfoItemTimestamp(props.item)} ); } const unboundStyles = { container: { marginTop: 5, backgroundColor: 'panelForeground', }, containerOverflow: { overflow: 'scroll', maxHeight: 400, }, viewContainer: { marginTop: 10, marginBottom: 10, }, messageDate: { color: 'messageLabel', fontSize: 12, marginLeft: 55, }, }; export default MessageResult; diff --git a/native/chat/robotext-message-tooltip-button.react.js b/native/chat/robotext-message-tooltip-button.react.js index 68923019c..d959253ba 100644 --- a/native/chat/robotext-message-tooltip-button.react.js +++ b/native/chat/robotext-message-tooltip-button.react.js @@ -1,142 +1,142 @@ // @flow import * as React from 'react'; import Animated from 'react-native-reanimated'; import { useViewerAlreadySelectedMessageReactions, useCanCreateReactionFromMessage, } from 'lib/shared/reaction-utils.js'; import { InnerRobotextMessage } from './inner-robotext-message.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 { Timestamp } from './timestamp.react.js'; import { useAnimatedMessageTooltipButton } from './utils.js'; import EmojiKeyboard from '../components/emoji-keyboard.react.js'; import type { EmojiSelection } from '../components/emoji-keyboard.react.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'; const { Node, interpolateNode, Extrapolate } = Animated; type Props = { +navigation: AppNavigationProp<'RobotextMessageTooltipModal'>, +route: TooltipRoute<'RobotextMessageTooltipModal'>, +progress: Node, ... }; function RobotextMessageTooltipButton(props: Props): React.Node { const { navigation, route, progress } = 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 { messageInfo, threadInfo, reactions } = item; const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); const sendReaction = useSendReaction(messageInfo.id, threadInfo, 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: EmojiSelection) => { sendReaction(emoji.emoji); dismissTooltip(); }, [sendReaction, dismissTooltip], ); const alreadySelectedEmojis = useViewerAlreadySelectedMessageReactions(reactions); return ( <> - + {reactionSelectionPopover} > ); } export default RobotextMessageTooltipButton; diff --git a/native/chat/robotext-message.react.js b/native/chat/robotext-message.react.js index 5058105ff..9e2c7b2c4 100644 --- a/native/chat/robotext-message.react.js +++ b/native/chat/robotext-message.react.js @@ -1,242 +1,240 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { chatMessageItemKey, chatMessageItemHasEngagement, } from 'lib/shared/chat-message-item-utils.js'; import { useCanCreateSidebarFromMessage } from 'lib/shared/sidebar-utils.js'; import { inlineEngagementCenterStyle } from './chat-constants.js'; import type { ChatNavigationProp } from './chat.react.js'; import { InlineEngagement } from './inline-engagement.react.js'; import { InnerRobotextMessage } from './inner-robotext-message.react.js'; import { Timestamp } from './timestamp.react.js'; import { getMessageTooltipKey, useContentAndHeaderOpacity } from './utils.js'; import { ChatContext } from '../chat/chat-context.js'; import { KeyboardContext } from '../keyboard/keyboard-state.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { OverlayContext } from '../navigation/overlay-context.js'; import { RobotextMessageTooltipModalRouteName } from '../navigation/route-names.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import { fixedTooltipHeight } from '../tooltip/tooltip.react.js'; import type { ChatRobotextMessageInfoItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; import { AnimatedView } from '../types/styles.js'; type Props = { ...React.ElementConfig, +item: ChatRobotextMessageInfoItemWithHeight, +navigation: | ChatNavigationProp<'MessageList'> | AppNavigationProp<'TogglePinModal'> | ChatNavigationProp<'PinnedMessagesScreen'> | ChatNavigationProp<'MessageSearch'>, +route: | NavigationRoute<'MessageList'> | NavigationRoute<'TogglePinModal'> | NavigationRoute<'PinnedMessagesScreen'> | NavigationRoute<'MessageSearch'>, +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 = ( - - ); + timestamp = ; } const styles = useStyles(unboundStyles); let inlineEngagement = null; if (chatMessageItemHasEngagement(item, item.threadInfo.id)) { inlineEngagement = ( ); } const chatContext = React.useContext(ChatContext); const keyboardState = React.useContext(KeyboardContext); const key = chatMessageItemKey(item); 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(() => { const result = []; if (item.threadCreatedFromMessage || canCreateSidebarFromMessage) { result.push('sidebar'); } return result; }, [item.threadCreatedFromMessage, canCreateSidebarFromMessage]); const openRobotextTooltipModal = React.useCallback( ( x: number, y: number, width: number, height: number, pageX: number, pageY: number, ) => { 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 = fixedTooltipHeight + belowMargin; const aboveMargin = 30; const aboveSpace = fixedTooltipHeight + aboveMargin; let margin = 0; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { margin = aboveMargin; } const currentInputBarHeight = chatContext?.chatInputBarHeights.get(item.threadInfo.id) ?? 0; props.navigation.navigate<'RobotextMessageTooltipModal'>({ name: RobotextMessageTooltipModalRouteName, params: { presentedFrom: props.route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs, tooltipLocation: 'fixed', margin, item, chatInputBarHeight: currentInputBarHeight, }, key: getMessageTooltipKey(item), }); }, [ item, props.navigation, props.route.key, verticalBounds, visibleEntryIDs, chatContext, ], ); const onLongPress = React.useCallback(() => { if (keyboardState && keyboardState.dismissKeyboardIfShowing()) { return; } if (visibleEntryIDs.length === 0) { return; } if (!viewRef.current || !verticalBounds) { return; } if (!focused) { toggleFocus(key); } invariant(overlayContext, 'RobotextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); viewRef.current?.measure(openRobotextTooltipModal); }, [ focused, key, keyboardState, overlayContext, toggleFocus, verticalBounds, viewRef, visibleEntryIDs, openRobotextTooltipModal, ]); const onLayout = React.useCallback(() => {}, []); const contentAndHeaderOpacity = useContentAndHeaderOpacity(item); const viewStyle: { height?: number } = {}; if (!__DEV__) { // We don't force view height in dev mode because we // want to measure it in Message to see if it's correct viewStyle.height = item.contentHeight; } return ( {timestamp} {inlineEngagement} ); } const unboundStyles = { sidebar: { marginTop: inlineEngagementCenterStyle.topOffset, marginBottom: -inlineEngagementCenterStyle.topOffset, alignSelf: 'center', }, }; export { RobotextMessage }; diff --git a/native/chat/timestamp.react.js b/native/chat/timestamp.react.js index e2c177790..366b0b653 100644 --- a/native/chat/timestamp.react.js +++ b/native/chat/timestamp.react.js @@ -1,56 +1,57 @@ // @flow import * as React from 'react'; -import { longAbsoluteDate } from 'lib/utils/date-utils.js'; +import { chatMessageInfoItemTimestamp } from 'lib/shared/chat-message-item-utils.js'; import SingleLine from '../components/single-line.react.js'; import { useStyles } from '../themes/colors.js'; +import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; export type DisplayType = 'lowContrast' | 'modal'; type Props = { - +time: number, + +item: ChatMessageInfoItemWithHeight, +display: DisplayType, }; function Timestamp(props: Props): React.Node { const styles = useStyles(unboundStyles); const style = React.useMemo( () => props.display === 'modal' ? [styles.timestamp, styles.modal] : [styles.timestamp], [props.display, styles.modal, styles.timestamp], ); const absoluteDate = React.useMemo( - () => longAbsoluteDate(props.time), - [props.time], + () => chatMessageInfoItemTimestamp(props.item), + [props.item], ); const timestamp = React.useMemo( () => {absoluteDate}, [absoluteDate, style], ); return timestamp; } const timestampHeight = 26; const unboundStyles = { modal: { // high contrast framed against OverlayNavigator-dimmed background color: 'white', }, timestamp: { alignSelf: 'center', bottom: 0, color: 'listBackgroundTernaryLabel', fontSize: 14, height: timestampHeight, paddingVertical: 3, }, }; export { Timestamp, timestampHeight }; diff --git a/web/chat/message.react.js b/web/chat/message.react.js index 2d513e93f..7031dabac 100644 --- a/web/chat/message.react.js +++ b/web/chat/message.react.js @@ -1,85 +1,85 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; +import { chatMessageInfoItemTimestamp } from 'lib/shared/chat-message-item-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import { longAbsoluteDate } from 'lib/utils/date-utils.js'; import css from './chat-message-list.css'; import { useEditModalContext } from './edit-message-provider.js'; import { ComposedEditTextMessage } from './edit-text-message.react.js'; import MultimediaMessage from './multimedia-message.react.js'; import RobotextMessage from './robotext-message.react.js'; import TextMessage from './text-message.react.js'; type Props = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +shouldDisplayPinIndicator: boolean, }; function Message(props: Props): React.Node { const { item } = props; let conversationHeader = null; if (item.startsConversation) { conversationHeader = ( - {longAbsoluteDate(item.messageInfo.time)} + {chatMessageInfoItemTimestamp(item)} ); } const { editState } = useEditModalContext(); let message; if ( item.messageInfoType === 'composable' && item.messageInfo.id && editState?.messageInfo.messageInfo?.id === item.messageInfo.id ) { message = ( ); } else if ( item.messageInfoType === 'composable' && item.messageInfo.type === messageTypes.TEXT ) { message = ( ); } else if ( item.messageInfoType === 'composable' && (item.messageInfo.type === messageTypes.IMAGES || item.messageInfo.type === messageTypes.MULTIMEDIA) ) { message = ( ); } else { invariant(item.robotext, "Flow can't handle our fancy types :("); message = ; } return ( {conversationHeader} {message} ); } export default Message; diff --git a/web/components/message-result.react.js b/web/components/message-result.react.js index b46eaf332..309e3ad2e 100644 --- a/web/components/message-result.react.js +++ b/web/components/message-result.react.js @@ -1,69 +1,69 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; +import { chatMessageInfoItemTimestamp } from 'lib/shared/chat-message-item-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import { longAbsoluteDate } from 'lib/utils/date-utils.js'; import css from './message-result.css'; import { MessageListContext } from '../chat/message-list-types.js'; import Message from '../chat/message.react.js'; import { useTextMessageRulesFunc } from '../markdown/rules.react.js'; type MessageResultProps = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +scrollable: boolean, }; function MessageResult(props: MessageResultProps): React.Node { const { item, threadInfo, scrollable } = props; const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const getTextMessageMarkdownRules = useTextMessageRulesFunc( threadInfo, chatMentionCandidates, ); const messageListContext = React.useMemo(() => { if (!getTextMessageMarkdownRules) { return undefined; } return { getTextMessageMarkdownRules }; }, [getTextMessageMarkdownRules]); const shouldShowUsername = !item.startsConversation && !item.startsCluster; const username = useStringForUser( shouldShowUsername ? item.messageInfo.creator : null, ); const messageContainerClassNames = classNames({ [css.messageContainer]: true, [css.messageContainerOverflow]: scrollable, }); return ( {username} - {longAbsoluteDate(item.messageInfo.time)} + {chatMessageInfoItemTimestamp(item)} ); } export default MessageResult; diff --git a/web/tooltips/tooltip-action-utils.js b/web/tooltips/tooltip-action-utils.js index 784753b4f..8b376f8d7 100644 --- a/web/tooltips/tooltip-action-utils.js +++ b/web/tooltips/tooltip-action-utils.js @@ -1,560 +1,557 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { useResettingState } from 'lib/hooks/use-resetting-state.js'; import type { ChatMessageInfoItem, ReactionInfo, } from 'lib/selectors/chat-selectors.js'; +import { chatMessageInfoItemTimestamp } from 'lib/shared/chat-message-item-utils.js'; import { useCanEditMessage } from 'lib/shared/edit-messages-utils.js'; import { createMessageReply } from 'lib/shared/message-utils.js'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js'; import { useSidebarExistsOrCanBeCreated } from 'lib/shared/sidebar-utils.js'; import { useThreadHasPermission } from 'lib/shared/thread-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; -import { longAbsoluteDate } from 'lib/utils/date-utils.js'; import { useCanToggleMessagePin } from 'lib/utils/message-pinning-utils.js'; import LabelTooltip from './label-toolitp.react.js'; import MessageTooltip from './message-tooltip.react.js'; import ReactionTooltip from './reaction-tooltip.react.js'; import { useTooltipContext } from './tooltip-provider.js'; import { calculateLabelTooltipSize, calculateMessageTooltipSize, calculateReactionTooltipSize, getTooltipPositionStyle, type MessageTooltipAction, type TooltipPosition, type TooltipPositionStyle, type TooltipSize, } from './tooltip-utils.js'; import { getComposedMessageID } from '../chat/chat-constants.js'; import { useEditModalContext } from '../chat/edit-message-provider.js'; import type { PositionInfo } from '../chat/position-types.js'; import CommIcon from '../comm-icon.react.js'; import { InputStateContext } from '../input/input-state.js'; import TogglePinModal from '../modals/chat/toggle-pin-modal.react.js'; import { useOnClickPendingSidebar, useOnClickThread, } from '../selectors/thread-selectors.js'; type UseTooltipArgs = { +createTooltip: (tooltipPositionStyle: TooltipPositionStyle) => React.Node, +tooltipSize: TooltipSize, +availablePositions: $ReadOnlyArray, }; type UseTooltipResult = { +onMouseEnter: (event: SyntheticEvent) => mixed, +onMouseLeave: ?() => mixed, }; function useTooltip({ createTooltip, tooltipSize, availablePositions, }: UseTooltipArgs): UseTooltipResult { const [onMouseLeave, setOnMouseLeave] = React.useState() => mixed>(null); const [tooltipSourcePosition, setTooltipSourcePosition] = React.useState(); const { renderTooltip } = useTooltipContext(); const updateTooltip = React.useRef(React.Node) => mixed>(); const onMouseEnter = React.useCallback( (event: SyntheticEvent) => { if (!renderTooltip) { return; } const rect = event.currentTarget.getBoundingClientRect(); const { top, bottom, left, right, height, width } = rect; const sourcePosition = { top, bottom, left, right, height, width }; setTooltipSourcePosition(sourcePosition); const tooltipPositionStyle = getTooltipPositionStyle({ tooltipSourcePosition: sourcePosition, tooltipSize, availablePositions, }); if (!tooltipPositionStyle) { return; } const tooltip = createTooltip(tooltipPositionStyle); const renderTooltipResult = renderTooltip({ newNode: tooltip, tooltipPositionStyle, }); if (renderTooltipResult) { const { onMouseLeaveCallback: callback } = renderTooltipResult; setOnMouseLeave((() => callback: () => () => mixed)); updateTooltip.current = renderTooltipResult.updateTooltip; } }, [availablePositions, createTooltip, renderTooltip, tooltipSize], ); React.useEffect(() => { if (!updateTooltip.current) { return; } const tooltipPositionStyle = getTooltipPositionStyle({ tooltipSourcePosition, tooltipSize, availablePositions, }); if (!tooltipPositionStyle) { return; } const tooltip = createTooltip(tooltipPositionStyle); updateTooltip.current?.(tooltip); }, [availablePositions, createTooltip, tooltipSize, tooltipSourcePosition]); return { onMouseEnter, onMouseLeave, }; } function useMessageTooltipSidebarAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { threadCreatedFromMessage, messageInfo } = item; const { popModal } = useModalContext(); const sidebarExists = !!threadCreatedFromMessage; const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( threadInfo, item, ); const openThread = useOnClickThread(threadCreatedFromMessage); const openPendingSidebar = useOnClickPendingSidebar(messageInfo, threadInfo); return React.useMemo(() => { if (!sidebarExistsOrCanBeCreated) { return null; } const buttonContent = ; const onClick = (event: SyntheticEvent) => { popModal(); if (threadCreatedFromMessage) { openThread(event); } else { openPendingSidebar(event); } }; return { actionButtonContent: buttonContent, onClick, label: sidebarExists ? 'Go to thread' : 'Create thread', }; }, [ popModal, openPendingSidebar, openThread, sidebarExists, sidebarExistsOrCanBeCreated, threadCreatedFromMessage, ]); } function useMessageTooltipReplyAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; const { popModal } = useModalContext(); const inputState = React.useContext(InputStateContext); invariant(inputState, 'inputState is required'); const { addReply } = inputState; const currentUserIsVoiced = useThreadHasPermission( threadInfo, threadPermissions.VOICED, ); return React.useMemo(() => { if (item.messageInfo.type !== messageTypes.TEXT || !currentUserIsVoiced) { return null; } const buttonContent = ; const onClick = () => { popModal(); if (!messageInfo.text) { return; } addReply(createMessageReply(messageInfo.text)); }; return { actionButtonContent: buttonContent, onClick, label: 'Reply', }; }, [ popModal, addReply, item.messageInfo.type, messageInfo, currentUserIsVoiced, ]); } const copiedMessageDurationMs = 2000; function useMessageCopyAction( item: ChatMessageInfoItem, ): ?MessageTooltipAction { const { messageInfo } = item; const [successful, setSuccessful] = useResettingState( false, copiedMessageDurationMs, ); return React.useMemo(() => { if (messageInfo.type !== messageTypes.TEXT) { return null; } const buttonContent = ; const onClick = async () => { try { await navigator.clipboard.writeText(messageInfo.text); setSuccessful(true); } catch (e) { setSuccessful(false); } }; return { actionButtonContent: buttonContent, onClick, label: successful ? 'Copied!' : 'Copy', }; }, [messageInfo.text, messageInfo.type, setSuccessful, successful]); } function useMessageReactAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; const { setShouldRenderEmojiKeyboard } = useTooltipContext(); const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); return React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } const buttonContent = ; const onClickReact = () => { if (!setShouldRenderEmojiKeyboard) { return; } setShouldRenderEmojiKeyboard(true); }; return { actionButtonContent: buttonContent, onClick: onClickReact, label: 'React', }; }, [canCreateReactionFromMessage, setShouldRenderEmojiKeyboard]); } function useMessageTogglePinAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { pushModal } = useModalContext(); const { messageInfo, isPinned } = item; const canTogglePin = useCanToggleMessagePin(messageInfo, threadInfo); const inputState = React.useContext(InputStateContext); return React.useMemo(() => { if (!canTogglePin) { return null; } const iconName = isPinned ? 'unpin' : 'pin'; const buttonContent = ; const onClickTogglePin = () => { pushModal( , ); }; return { actionButtonContent: buttonContent, onClick: onClickTogglePin, label: isPinned ? 'Unpin' : 'Pin', }; }, [canTogglePin, inputState, isPinned, pushModal, item, threadInfo]); } function useMessageEditAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; const canEditMessage = useCanEditMessage(threadInfo, messageInfo); const { renderEditModal, scrollToMessage } = useEditModalContext(); const { clearTooltip } = useTooltipContext(); return React.useMemo(() => { if (!canEditMessage) { return null; } invariant( item.messageInfoType === 'composable', 'canEditMessage should only be true for composable messages!', ); const buttonContent = ; const onClickEdit = () => { const callback = (maxHeight: number) => renderEditModal({ messageInfo: item, threadInfo, isError: false, editedMessageDraft: messageInfo.text, maxHeight: maxHeight, }); clearTooltip(); scrollToMessage(getComposedMessageID(messageInfo), callback); }; return { actionButtonContent: buttonContent, onClick: onClickEdit, label: 'Edit', }; }, [ canEditMessage, clearTooltip, item, messageInfo, renderEditModal, scrollToMessage, threadInfo, ]); } function useMessageTooltipActions( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): $ReadOnlyArray { const sidebarAction = useMessageTooltipSidebarAction(item, threadInfo); const replyAction = useMessageTooltipReplyAction(item, threadInfo); const copyAction = useMessageCopyAction(item); const reactAction = useMessageReactAction(item, threadInfo); const togglePinAction = useMessageTogglePinAction(item, threadInfo); const editAction = useMessageEditAction(item, threadInfo); return React.useMemo( () => [ replyAction, sidebarAction, copyAction, reactAction, togglePinAction, editAction, ].filter(Boolean), [ replyAction, sidebarAction, copyAction, reactAction, togglePinAction, editAction, ], ); } const undefinedTooltipSize = { width: 0, height: 0, }; type UseMessageTooltipArgs = { +availablePositions: $ReadOnlyArray, +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, }; function useMessageTooltip({ availablePositions, item, threadInfo, }: UseMessageTooltipArgs): UseTooltipResult { const tooltipActions = useMessageTooltipActions(item, threadInfo); - const messageTimestamp = React.useMemo(() => { - const time = item.messageInfo.time; - return longAbsoluteDate(time); - }, [item.messageInfo.time]); + const messageTimestamp = chatMessageInfoItemTimestamp(item); const tooltipSize = React.useMemo(() => { if (typeof document === 'undefined') { return undefinedTooltipSize; } const tooltipLabels = tooltipActions.map(action => action.label); return calculateMessageTooltipSize({ tooltipLabels, timestamp: messageTimestamp, }); }, [messageTimestamp, tooltipActions]); const createMessageTooltip = React.useCallback( (tooltipPositionStyle: TooltipPositionStyle) => ( ), [item, messageTimestamp, threadInfo, tooltipActions, tooltipSize], ); const { onMouseEnter, onMouseLeave } = useTooltip({ createTooltip: createMessageTooltip, tooltipSize, availablePositions, }); return { onMouseEnter, onMouseLeave, }; } const useENSNamesOptions = { allAtOnce: true }; type UseReactionTooltipArgs = { +reaction: string, +reactions: ReactionInfo, +availablePositions: $ReadOnlyArray, }; function useReactionTooltip({ reaction, reactions, availablePositions, }: UseReactionTooltipArgs): UseTooltipResult { const { users } = reactions[reaction]; const resolvedUsers = useENSNames(users, useENSNamesOptions); const showSeeMoreText = resolvedUsers.length > 5; const usernamesToShow = resolvedUsers .map(user => user.username) .filter(Boolean) .slice(0, 5); const tooltipSize = React.useMemo(() => { if (typeof document === 'undefined') { return undefinedTooltipSize; } return calculateReactionTooltipSize(usernamesToShow, showSeeMoreText); }, [showSeeMoreText, usernamesToShow]); const createReactionTooltip = React.useCallback( () => ( ), [reactions, showSeeMoreText, usernamesToShow], ); const { onMouseEnter, onMouseLeave } = useTooltip({ createTooltip: createReactionTooltip, tooltipSize, availablePositions, }); return { onMouseEnter, onMouseLeave, }; } type UseLabelTooltipArgs = { +tooltipLabel: string, +position: TooltipPosition, // The margin size should be between the point of origin and // the base of the tooltip. The arrow is a "decoration" and // should not be considered when measuring the margin size. +tooltipMargin: number, }; function useLabelTooltip({ tooltipLabel, position, tooltipMargin, }: UseLabelTooltipArgs): UseTooltipResult { const tooltipSize = React.useMemo(() => { if (typeof document === 'undefined') { return undefinedTooltipSize; } return calculateLabelTooltipSize(tooltipLabel, position, tooltipMargin); }, [position, tooltipLabel, tooltipMargin]); const createLabelTooltip = React.useCallback( () => ( ), [position, tooltipLabel, tooltipMargin], ); const { onMouseEnter, onMouseLeave } = useTooltip({ createTooltip: createLabelTooltip, tooltipSize, availablePositions: [position], }); return { onMouseEnter, onMouseLeave, }; } export { useMessageTooltipSidebarAction, useMessageTooltipReplyAction, useMessageReactAction, useMessageTooltipActions, useMessageTooltip, useReactionTooltip, useLabelTooltip, };