diff --git a/lib/shared/chat-message-item-utils.js b/lib/shared/chat-message-item-utils.js index 37079eba4..91e528702 100644 --- a/lib/shared/chat-message-item-utils.js +++ b/lib/shared/chat-message-item-utils.js @@ -1,75 +1,82 @@ // @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); } +function chatMessageItemEngagementTargetMessageInfo( + item: BaseChatMessageInfoItem, +): ComposableMessageInfo | RobotextMessageInfo { + return item.messageInfo; +} + function chatMessageItemHasNonViewerMessage( item: BaseChatMessageItem, viewerID: ?string, ): boolean { return ( item.itemType === 'message' && item.messageInfo.creator.id !== viewerID ); } 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, chatMessageInfoItemTimestamp, + chatMessageItemEngagementTargetMessageInfo, chatMessageItemHasNonViewerMessage, chatMessageItemHasEngagement, }; diff --git a/lib/shared/sidebar-utils.js b/lib/shared/sidebar-utils.js index bc9cb718b..fc09df8f6 100644 --- a/lib/shared/sidebar-utils.js +++ b/lib/shared/sidebar-utils.js @@ -1,244 +1,247 @@ // @flow import invariant from 'invariant'; import type { ParserRules } from './markdown.js'; import { getMessageTitle, isInvalidSidebarSource } from './message-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import { createPendingThread, getSingleOtherUser, extractMentionedMembers, useThreadHasPermission, userIsMember, } from './thread-utils.js'; import type { ChatMessageInfoItem } from '../selectors/chat-selectors.js'; +import { chatMessageItemEngagementTargetMessageInfo } from '../shared/chat-message-item-utils.js'; import { messageTypes } from '../types/message-types-enum.js'; import type { RobotextMessageInfo, ComposableMessageInfo, } from '../types/message-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { threadTypes, threadTypeIsThick, threadTypeIsPersonal, } from '../types/thread-types-enum.js'; import type { LoggedInUserInfo } from '../types/user-types.js'; import type { GetENSNames } from '../utils/ens-helpers.js'; import { entityTextToRawString, getEntityTextAsString, } from '../utils/entity-text.js'; import type { GetFCNames } from '../utils/farcaster-helpers.js'; import { useSelector } from '../utils/redux-utils.js'; import { trimText } from '../utils/text-utils.js'; type SharedCreatePendingSidebarInput = { +sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, +parentThreadInfo: ThreadInfo, +loggedInUserInfo: LoggedInUserInfo, }; type BaseCreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +messageTitle: string, }; type UserIDAndUsername = { +id: string, +username: ?string, ... }; function baseCreatePendingSidebar( input: BaseCreatePendingSidebarInput, ): ThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, messageTitle, } = input; const { color, type: parentThreadType } = parentThreadInfo; const threadName = trimText(messageTitle, 30); const initialMembers = new Map(); const { id: viewerID, username: viewerUsername } = loggedInUserInfo; initialMembers.set(viewerID, { id: viewerID, username: viewerUsername }); if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) { const { id: sourceAuthorID, username: sourceAuthorUsername } = sourceMessageInfo.creator; const initialMemberUserInfo = { id: sourceAuthorID, username: sourceAuthorUsername, }; initialMembers.set(sourceAuthorID, initialMemberUserInfo); } const singleOtherUser = getSingleOtherUser(parentThreadInfo, viewerID); if (threadTypeIsPersonal(parentThreadType) && singleOtherUser) { const singleOtherUsername = parentThreadInfo.members.find( member => member.id === singleOtherUser, )?.username; const singleOtherUserInfo = { id: singleOtherUser, username: singleOtherUsername, }; initialMembers.set(singleOtherUser, singleOtherUserInfo); } if (sourceMessageInfo.type === messageTypes.TEXT) { const mentionedMembersOfParent = extractMentionedMembers( sourceMessageInfo.text, parentThreadInfo, ); for (const [memberID, member] of mentionedMembersOfParent) { initialMembers.set(memberID, member); } } return createPendingThread({ viewerID, threadType: threadTypeIsThick(parentThreadInfo.type) ? threadTypes.THICK_SIDEBAR : threadTypes.SIDEBAR, members: [...initialMembers.values()], parentThreadInfo, threadColor: color, name: threadName, sourceMessageID: sourceMessageInfo.id, }); } // The message title here may have ETH addresses that aren't resolved to ENS // names. This function should only be used in cases where we're sure that we // don't care about the thread title. We should prefer createPendingSidebar // wherever possible type CreateUnresolvedPendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, }; function createUnresolvedPendingSidebar( input: CreateUnresolvedPendingSidebarInput, ): ThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = entityTextToRawString(messageTitleEntityText, { ignoreViewer: true, }); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } type CreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, +getENSNames: ?GetENSNames, +getFCNames: ?GetFCNames, }; async function createPendingSidebar( input: CreatePendingSidebarInput, ): Promise { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, getENSNames, getFCNames, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = await getEntityTextAsString( messageTitleEntityText, { getENSNames, getFCNames }, { ignoreViewer: true }, ); invariant( messageTitle !== null && messageTitle !== undefined, 'getEntityTextAsString only returns falsey when passed falsey', ); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } function useCanCreateSidebarFromMessage( threadInfo: ThreadInfo, messageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const messageCreatorUserInfo = useSelector( state => state.userStore.userInfos[messageInfo.creator.id], ); const hasCreateSidebarsPermission = useThreadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); if (!hasCreateSidebarsPermission) { return false; } if ( (!messageInfo.id && !threadTypeIsThick(threadInfo.type)) || (threadInfo.sourceMessageID && threadInfo.sourceMessageID === messageInfo.id) || isInvalidSidebarSource(messageInfo) ) { return false; } const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; return ( !messageCreatorRelationship || !relationshipBlockedInEitherDirection(messageCreatorRelationship) ); } function useSidebarExistsOrCanBeCreated( threadInfo: ThreadInfo, messageItem: ChatMessageInfoItem, ): boolean { + const engagementTargetMessageInfo = + chatMessageItemEngagementTargetMessageInfo(messageItem); const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( threadInfo, - messageItem.messageInfo, + engagementTargetMessageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } export { createUnresolvedPendingSidebar, createPendingSidebar, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, }; diff --git a/native/chat/robotext-message-tooltip-button.react.js b/native/chat/robotext-message-tooltip-button.react.js index d959253ba..9fa1179f2 100644 --- a/native/chat/robotext-message-tooltip-button.react.js +++ b/native/chat/robotext-message-tooltip-button.react.js @@ -1,142 +1,149 @@ // @flow import * as React from 'react'; import Animated from 'react-native-reanimated'; +import { chatMessageItemEngagementTargetMessageInfo } from 'lib/shared/chat-message-item-utils.js'; 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 { threadInfo, reactions } = item; + const engagementTargetMessageInfo = + chatMessageItemEngagementTargetMessageInfo(item); const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, - messageInfo, + engagementTargetMessageInfo, ); - const sendReaction = useSendReaction(messageInfo.id, threadInfo, reactions); + const sendReaction = useSendReaction( + engagementTargetMessageInfo?.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 9e2c7b2c4..17c515df9 100644 --- a/native/chat/robotext-message.react.js +++ b/native/chat/robotext-message.react.js @@ -1,240 +1,243 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { chatMessageItemKey, chatMessageItemHasEngagement, + chatMessageItemEngagementTargetMessageInfo, } 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 = ; } const styles = useStyles(unboundStyles); + const engagementTargetMessageInfo = + chatMessageItemEngagementTargetMessageInfo(item); 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, + engagementTargetMessageInfo, ); 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/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js index ecb0cbf31..92c055090 100644 --- a/web/chat/robotext-message.react.js +++ b/web/chat/robotext-message.react.js @@ -1,162 +1,165 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { type RobotextChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; +import { chatMessageItemEngagementTargetMessageInfo } from 'lib/shared/chat-message-item-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { entityTextToReact, useResolvedEntityText, } from 'lib/utils/entity-text.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import InlineEngagement from './inline-engagement.react.js'; import css from './robotext-message.css'; import Markdown from '../markdown/markdown.react.js'; import { linkRules } from '../markdown/rules.react.js'; import { usePushUserProfileModal } from '../modals/user-profile/user-profile-utils.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import { useMessageTooltip } from '../tooltips/tooltip-action-utils.js'; import { tooltipPositions } from '../tooltips/tooltip-utils.js'; const availableTooltipPositionsForRobotext = [ tooltipPositions.LEFT, tooltipPositions.LEFT_TOP, tooltipPositions.LEFT_BOTTOM, tooltipPositions.RIGHT, tooltipPositions.RIGHT_TOP, tooltipPositions.RIGHT_BOTTOM, ]; type Props = { +item: RobotextChatMessageInfoItem, +threadInfo: ThreadInfo, }; function RobotextMessage(props: Props): React.Node { let inlineEngagement; const { item, threadInfo } = props; const { threadCreatedFromMessage, reactions } = item; if (threadCreatedFromMessage || Object.keys(reactions).length > 0) { + const engagementTargetMessageInfo = + chatMessageItemEngagementTargetMessageInfo(item); inlineEngagement = (
); } const { messageInfo, robotext } = item; const { threadID } = messageInfo; const resolvedRobotext = useResolvedEntityText(robotext); invariant( resolvedRobotext, 'useResolvedEntityText only returns falsey when passed falsey', ); const textParts = React.useMemo(() => { return entityTextToReact(resolvedRobotext, threadID, { renderText: ({ text }) => ( {text} ), renderThread: ({ id, name }) => , renderUser: ({ userID, usernameText }) => ( ), renderColor: ({ hex }) => , renderFarcasterUser: ({ farcasterUsername }) => ( {farcasterUsername} ), }); }, [resolvedRobotext, threadID]); const { onMouseEnter, onMouseLeave } = useMessageTooltip({ item, threadInfo, availablePositions: availableTooltipPositionsForRobotext, }); return (
{textParts}
{inlineEngagement}
); } type BaseInnerThreadEntityProps = { +id: string, +name: string, }; type InnerThreadEntityProps = { ...BaseInnerThreadEntityProps, +threadInfo: ThreadInfo, +dispatch: Dispatch, }; class InnerThreadEntity extends React.PureComponent { render(): React.Node { return {this.props.name}; } onClickThread = (event: SyntheticEvent) => { event.preventDefault(); const id = this.props.id; this.props.dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: id, }, }); }; } const ThreadEntity = React.memo( function ConnectedInnerThreadEntity(props: BaseInnerThreadEntityProps) { const { id } = props; const threadInfo = useSelector(state => threadInfoSelector(state)[id]); const dispatch = useDispatch(); return ( ); }, ); type UserEntityProps = { +userID: string, +usernameText: string, }; function UserEntity(props: UserEntityProps) { const { userID, usernameText } = props; const pushUserProfileModal = usePushUserProfileModal(userID); return {usernameText}; } function ColorEntity(props: { color: string }) { const colorStyle = { color: props.color }; return {props.color}; } const MemoizedRobotextMessage: React.ComponentType = React.memo(RobotextMessage); export default MemoizedRobotextMessage; diff --git a/web/modals/chat/toggle-pin-modal.react.js b/web/modals/chat/toggle-pin-modal.react.js index cdfeb079c..d1294ca81 100644 --- a/web/modals/chat/toggle-pin-modal.react.js +++ b/web/modals/chat/toggle-pin-modal.react.js @@ -1,143 +1,150 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { toggleMessagePinActionTypes, useToggleMessagePin, } from 'lib/actions/message-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; +import { chatMessageItemEngagementTargetMessageInfo } from 'lib/shared/chat-message-item-utils.js'; import { modifyItemForResultScreen } from 'lib/shared/message-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import css from './toggle-pin-modal.css'; import Button, { buttonThemes } from '../../components/button.react.js'; import MessageResult from '../../components/message-result.react.js'; import Modal from '../modal.react.js'; type TogglePinModalProps = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, }; function TogglePinModal(props: TogglePinModalProps): React.Node { const { item, threadInfo } = props; - const { messageInfo, isPinned } = item; + const { isPinned } = item; const { popModal } = useModalContext(); const callToggleMessagePin = useToggleMessagePin(); const dispatchActionPromise = useDispatchActionPromise(); const modalInfo = React.useMemo(() => { if (isPinned) { return { name: 'Remove Pinned Message', action: 'unpin', confirmationText: 'Are you sure you want to remove this pinned message?', buttonText: 'Remove Pinned Message', buttonColor: buttonThemes.danger, }; } return { name: 'Pin Message', action: 'pin', confirmationText: `You may pin this message to the channel you are currently viewing. To unpin a message, select the pinned messages icon in the channel.`, buttonText: 'Pin Message', buttonColor: buttonThemes.standard, }; }, [isPinned]); // We want to remove inline engagement (threadCreatedFromMessage / reactions) // and the message header (startsConversation). We also want to set isViewer // to false so that the message is left-aligned and uncolored. const modifiedItem = React.useMemo(() => { if (item.messageInfoType !== 'composable') { return item; } const strippedItem = { ...item, threadCreatedFromMessage: undefined, reactions: {}, }; return modifyItemForResultScreen(strippedItem); }, [item]); + const engagementTargetMessageInfo = + chatMessageItemEngagementTargetMessageInfo(item); + const engagementTargetMessageID = engagementTargetMessageInfo?.id; const onClick = React.useCallback(() => { const createToggleMessagePinPromise = async () => { - invariant(messageInfo.id, 'messageInfo.id should be defined'); + invariant( + engagementTargetMessageID, + 'engagement target messageID should be defined', + ); const result = await callToggleMessagePin({ - messageID: messageInfo.id, + messageID: engagementTargetMessageID, action: modalInfo.action, }); return ({ newMessageInfos: result.newMessageInfos, threadID: result.threadID, }: { +newMessageInfos: $ReadOnlyArray, +threadID: string, }); }; void dispatchActionPromise( toggleMessagePinActionTypes, createToggleMessagePinPromise(), ); popModal(); }, [ modalInfo, callToggleMessagePin, dispatchActionPromise, - messageInfo.id, + engagementTargetMessageID, popModal, ]); const primaryButton = React.useMemo( () => ( ), [modalInfo.buttonColor, modalInfo.buttonText, onClick], ); const secondaryButton = React.useMemo( () => ( ), [popModal], ); return (
{modalInfo.confirmationText}
); } export default TogglePinModal; diff --git a/web/tooltips/message-tooltip.react.js b/web/tooltips/message-tooltip.react.js index 3f7a1219f..25adb9d17 100644 --- a/web/tooltips/message-tooltip.react.js +++ b/web/tooltips/message-tooltip.react.js @@ -1,224 +1,231 @@ // @flow import data from '@emoji-mart/data'; import Picker from '@emoji-mart/react'; import classNames from 'classnames'; import * as React from 'react'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; +import { chatMessageItemEngagementTargetMessageInfo } from 'lib/shared/chat-message-item-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import css from './message-tooltip.css'; import { tooltipButtonStyle, tooltipLabelStyle, tooltipStyle, } from './tooltip-constants.js'; import { useTooltipContext } from './tooltip-provider.js'; import type { MessageTooltipAction, TooltipPositionStyle, TooltipSize, } from './tooltip-utils.js'; import { getEmojiKeyboardPosition, useSendReaction, } from '../chat/reaction-message-utils.js'; type MessageTooltipProps = { +actions: $ReadOnlyArray, +messageTimestamp: string, +tooltipPositionStyle: TooltipPositionStyle, +tooltipSize: TooltipSize, +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, }; function MessageTooltip(props: MessageTooltipProps): React.Node { const { actions, messageTimestamp, tooltipPositionStyle, tooltipSize, item, threadInfo, } = props; - const { messageInfo, reactions } = item; + const { reactions } = item; const { alignment = 'left' } = tooltipPositionStyle; const [activeTooltipLabel, setActiveTooltipLabel] = React.useState(); const { shouldRenderEmojiKeyboard } = useTooltipContext(); // emoji-mart actually doesn't render its contents until a useEffect runs: // https://github.com/missive/emoji-mart/blob/d29728f7b4e295e46f9b64aa80335aa4a3c15b8e/packages/emoji-mart-react/react.tsx#L13-L19 // We need to measure the width/height of the picker, but because of this we // need to do the measurement in our own useEffect, in order to guarantee it // runs after emoji-mart's useEffect. To do this, we have to define two pieces // of React state: // - emojiKeyboardNode, which will get set by the emoji keyboard's ref and // will trigger our useEffect // - emojiKeyboardRenderedNode, which will get set in that useEffect and will // trigger the rerendering of this component with the correct height/width const [emojiKeyboardNode, setEmojiKeyboardNode] = React.useState(null); const [emojiKeyboardRenderedNode, setEmojiKeyboardRenderedNode] = React.useState(null); React.useEffect(() => { if (emojiKeyboardNode) { // It would be more simple to just call getEmojiKeyboardPosition // immediately here, but some quirk of emoji-mart causes the width of the // node to be 0 here. If instead we wait until the next render of this // component to check the width, it ends up being set correctly. setEmojiKeyboardRenderedNode(emojiKeyboardNode); } }, [emojiKeyboardNode]); const messageActionButtonsContainerClassName = classNames( css.messageActionContainer, css.messageActionButtons, ); const messageTooltipButtonStyle = React.useMemo(() => tooltipButtonStyle, []); const tooltipButtons = React.useMemo(() => { if (!actions || actions.length === 0) { return null; } const buttons = actions.map(({ label, onClick, actionButtonContent }) => { const onMouseEnter = () => { setActiveTooltipLabel(label); }; const onMouseLeave = () => setActiveTooltipLabel(oldLabel => label === oldLabel ? null : oldLabel, ); return (
{actionButtonContent}
); }); return (
{buttons}
); }, [ actions, messageActionButtonsContainerClassName, messageTooltipButtonStyle, ]); const messageTooltipLabelStyle = React.useMemo(() => tooltipLabelStyle, []); const messageTooltipTopLabelStyle = React.useMemo( () => ({ height: `${tooltipLabelStyle.height + 2 * tooltipLabelStyle.padding}px`, }), [], ); const tooltipLabel = React.useMemo(() => { if (!activeTooltipLabel) { return null; } return (
{activeTooltipLabel}
); }, [activeTooltipLabel, messageTooltipLabelStyle]); const tooltipTimestamp = React.useMemo(() => { if (!messageTimestamp) { return null; } return (
{messageTimestamp}
); }, [messageTimestamp, messageTooltipLabelStyle]); const emojiKeyboardPosition = React.useMemo( () => getEmojiKeyboardPosition( emojiKeyboardRenderedNode, tooltipPositionStyle, tooltipSize, ), [emojiKeyboardRenderedNode, tooltipPositionStyle, tooltipSize], ); const emojiKeyboardPositionStyle = React.useMemo(() => { if (!emojiKeyboardPosition) { return null; } return { bottom: emojiKeyboardPosition.bottom, left: emojiKeyboardPosition.left, }; }, [emojiKeyboardPosition]); - const sendReaction = useSendReaction(messageInfo.id, threadInfo, reactions); + const engagementTargetMessageInfo = + chatMessageItemEngagementTargetMessageInfo(item); + const sendReaction = useSendReaction( + engagementTargetMessageInfo?.id, + threadInfo, + reactions, + ); const onEmojiSelect = React.useCallback( (emoji: { +native: string, ... }) => { const reactionInput = emoji.native; sendReaction(reactionInput); }, [sendReaction], ); const emojiKeyboard = React.useMemo(() => { if (!shouldRenderEmojiKeyboard) { return null; } return (
); }, [emojiKeyboardPositionStyle, onEmojiSelect, shouldRenderEmojiKeyboard]); const messageTooltipContainerStyle = React.useMemo(() => tooltipStyle, []); const containerClassName = classNames({ [css.messageTooltipContainer]: true, [css.leftTooltipAlign]: alignment === 'left', [css.centerTooltipAlign]: alignment === 'center', [css.rightTooltipAlign]: alignment === 'right', }); return ( <> {emojiKeyboard}
{tooltipLabel}
{tooltipButtons} {tooltipTimestamp}
); } export default MessageTooltip; diff --git a/web/tooltips/tooltip-action-utils.js b/web/tooltips/tooltip-action-utils.js index 8b376f8d7..7e1a8cd22 100644 --- a/web/tooltips/tooltip-action-utils.js +++ b/web/tooltips/tooltip-action-utils.js @@ -1,557 +1,565 @@ // @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 { + chatMessageInfoItemTimestamp, + chatMessageItemEngagementTargetMessageInfo, +} 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 { 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 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 { isPinned } = item; - const canTogglePin = useCanToggleMessagePin(messageInfo, threadInfo); + const engagementTargetMessageInfo = + chatMessageItemEngagementTargetMessageInfo(item); + const canTogglePin = useCanToggleMessagePin( + engagementTargetMessageInfo, + 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 = 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, };