diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index d438fb3a3..25f345824 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,713 +1,714 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _map from 'lodash/fp/map.js'; import _orderBy from 'lodash/fp/orderBy.js'; import * as React from 'react'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { threadInfoFromSourceMessageIDSelector, threadInfoSelector, } from './thread-selectors.js'; import { sidebarInfoSelector } from '../selectors/sidebar-selectors.js'; import { createMessageInfo, getMostRecentNonLocalMessageID, messageKey, robotextForMessageInfo, sortMessageInfoList, } from '../shared/message-utils.js'; import { threadInChatList, threadIsPending, threadIsTopLevel, } from '../shared/thread-utils.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type ComposableMessageInfo, isComposableMessageType, type LocalMessageInfo, type MessageInfo, type MessageStore, type RobotextMessageInfo, } from '../types/message-types.js'; import type { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { threadTypeIsSidebar } from '../types/thread-types-enum.js'; import { maxReadSidebars, maxUnreadSidebars, type SidebarInfo, } from '../types/thread-types.js'; import type { AccountUserInfo, RelativeUserInfo, UserInfo, } from '../types/user-types.js'; import { threeDays } from '../utils/date-utils.js'; import type { EntityText } from '../utils/entity-text.js'; import memoize2 from '../utils/memoize.js'; import { useSelector } from '../utils/redux-utils.js'; export type SidebarItem = | { ...SidebarInfo, +type: 'sidebar', } | { +type: 'seeMore', +unread: boolean, } | { +type: 'spacer' }; export type ChatThreadItem = { +type: 'chatThreadItem', +threadInfo: ThreadInfo, +mostRecentMessageInfo: ?MessageInfo, +mostRecentNonLocalMessage: ?string, +lastUpdatedTime: number, +lastUpdatedTimeIncludingSidebars: number, +sidebars: $ReadOnlyArray, +pendingPersonalThreadUserInfo?: UserInfo, }; const messageInfoSelector: (state: BaseAppState<>) => { +[id: string]: ?MessageInfo, } = createObjectSelector( (state: BaseAppState<>) => state.messageStore.messages, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.userStore.userInfos, threadInfoSelector, createMessageInfo, ); function isEmptyMediaMessage(messageInfo: MessageInfo): boolean { return ( (messageInfo.type === messageTypes.MULTIMEDIA || messageInfo.type === messageTypes.IMAGES) && messageInfo.media.length === 0 ); } function getMostRecentMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { +[id: string]: ?MessageInfo }, ): ?MessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (const messageID of thread.messageIDs) { const messageInfo = messages[messageID]; if (!messageInfo || isEmptyMediaMessage(messageInfo)) { continue; } return messageInfo; } return null; } function getLastUpdatedTime( threadInfo: ThreadInfo, mostRecentMessageInfo: ?MessageInfo, ): number { return mostRecentMessageInfo ? mostRecentMessageInfo.time : threadInfo.creationTime; } function createChatThreadItem( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { +[id: string]: ?MessageInfo }, sidebarInfos: ?$ReadOnlyArray, ): ChatThreadItem { const mostRecentMessageInfo = getMostRecentMessageInfo( threadInfo, messageStore, messages, ); const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( threadInfo.id, messageStore, ); const lastUpdatedTime = getLastUpdatedTime(threadInfo, mostRecentMessageInfo); const sidebars = sidebarInfos ?? []; const allSidebarItems = sidebars.map(sidebarInfo => ({ type: 'sidebar', ...sidebarInfo, })); const lastUpdatedTimeIncludingSidebars = allSidebarItems.length > 0 ? Math.max(lastUpdatedTime, allSidebarItems[0].lastUpdatedTime) : lastUpdatedTime; const numUnreadSidebars = allSidebarItems.filter( sidebar => sidebar.threadInfo.currentUser.unread, ).length; let numReadSidebarsToShow = maxReadSidebars - numUnreadSidebars; const threeDaysAgo = Date.now() - threeDays; const sidebarItems: SidebarItem[] = []; for (const sidebar of allSidebarItems) { if (sidebarItems.length >= maxUnreadSidebars) { break; } else if (sidebar.threadInfo.currentUser.unread) { sidebarItems.push(sidebar); } else if ( sidebar.lastUpdatedTime > threeDaysAgo && numReadSidebarsToShow > 0 ) { sidebarItems.push(sidebar); numReadSidebarsToShow--; } } const numReadButRecentSidebars = allSidebarItems.filter( sidebar => !sidebar.threadInfo.currentUser.unread && sidebar.lastUpdatedTime > threeDaysAgo, ).length; if ( sidebarItems.length < numUnreadSidebars + numReadButRecentSidebars || (sidebarItems.length < allSidebarItems.length && sidebarItems.length > 0) ) { sidebarItems.push({ type: 'seeMore', unread: numUnreadSidebars > maxUnreadSidebars, }); } if (sidebarItems.length !== 0) { sidebarItems.push({ type: 'spacer', }); } return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo, mostRecentNonLocalMessage, lastUpdatedTime, lastUpdatedTimeIncludingSidebars, sidebars: sidebarItems, }; } const chatListData: (state: BaseAppState<>) => $ReadOnlyArray = createSelector( threadInfoSelector, (state: BaseAppState<>) => state.messageStore, messageInfoSelector, sidebarInfoSelector, ( threadInfos: { +[id: string]: ThreadInfo, }, messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, sidebarInfos: { +[id: string]: $ReadOnlyArray }, ): $ReadOnlyArray => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, threadIsTopLevel, ), ); function useFlattenedChatListData(): $ReadOnlyArray { return useFilteredChatListData(threadInChatList); } function useFilteredChatListData( filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean, ): $ReadOnlyArray { const threadInfos = useSelector(threadInfoSelector); const messageInfos = useSelector(messageInfoSelector); const sidebarInfos = useSelector(sidebarInfoSelector); const messageStore = useSelector(state => state.messageStore); return React.useMemo( () => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, filterFunction, ), [messageInfos, messageStore, sidebarInfos, filterFunction, threadInfos], ); } function getChatThreadItems( threadInfos: { +[id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, sidebarInfos: { +[id: string]: $ReadOnlyArray }, filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean, ): $ReadOnlyArray { return _flow( _filter(filterFunction), _map((threadInfo: ThreadInfo): ChatThreadItem => createChatThreadItem( threadInfo, messageStore, messageInfos, sidebarInfos[threadInfo.id], ), ), _orderBy('lastUpdatedTimeIncludingSidebars')('desc'), )(threadInfos); } export type RobotextChatMessageInfoItem = { +itemType: 'message', +messageInfoType: 'robotext', +messageInfo: RobotextMessageInfo, +startsConversation: boolean, +startsCluster: boolean, endsCluster: boolean, +robotext: EntityText, +threadCreatedFromMessage: ?ThreadInfo, +reactions: ReactionInfo, }; +export type ComposableChatMessageInfoItem = { + +itemType: 'message', + +messageInfoType: 'composable', + +messageInfo: ComposableMessageInfo, + +localMessageInfo: ?LocalMessageInfo, + +startsConversation: boolean, + +startsCluster: boolean, + endsCluster: boolean, + +threadCreatedFromMessage: ?ThreadInfo, + +reactions: ReactionInfo, + +hasBeenEdited: boolean, + +isPinned: boolean, +}; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem - | { - +itemType: 'message', - +messageInfoType: 'composable', - +messageInfo: ComposableMessageInfo, - +localMessageInfo: ?LocalMessageInfo, - +startsConversation: boolean, - +startsCluster: boolean, - endsCluster: boolean, - +threadCreatedFromMessage: ?ThreadInfo, - +reactions: ReactionInfo, - +hasBeenEdited: boolean, - +isPinned: boolean, - }; + | ComposableChatMessageInfoItem; export type ChatMessageItem = { itemType: 'loader' } | ChatMessageInfoItem; export type ReactionInfo = { +[reaction: string]: MessageReactionInfo }; type MessageReactionInfo = { +viewerReacted: boolean, +users: $ReadOnlyArray, }; type TargetMessageReactions = Map>; const msInFiveMinutes = 5 * 60 * 1000; function createChatMessageItems( threadID: string, messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, threadInfos: { +[id: string]: ThreadInfo }, threadInfoFromSourceMessageID: { +[id: string]: ThreadInfo, }, additionalMessages: $ReadOnlyArray, viewerID: string, ): ChatMessageItem[] { const thread = messageStore.threads[threadID]; const threadMessageInfos = (thread?.messageIDs ?? []) .map((messageID: string) => messageInfos[messageID]) .filter(Boolean); const messages = additionalMessages.length > 0 ? sortMessageInfoList([...threadMessageInfos, ...additionalMessages]) : threadMessageInfos; const targetMessageReactionsMap = new Map(); // We need to iterate backwards to put the order of messages in chronological // order, starting with the oldest. This avoids the scenario where the most // recent message with the remove_reaction action may try to remove a user // that hasn't been added to the messageReactionUsersInfoMap, causing it // to be skipped. for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if (messageInfo.type !== messageTypes.REACTION) { continue; } if (!targetMessageReactionsMap.has(messageInfo.targetMessageID)) { const reactsMap: TargetMessageReactions = new Map(); targetMessageReactionsMap.set(messageInfo.targetMessageID, reactsMap); } const messageReactsMap = targetMessageReactionsMap.get( messageInfo.targetMessageID, ); invariant(messageReactsMap, 'messageReactsInfo should be set'); if (!messageReactsMap.has(messageInfo.reaction)) { const usersInfoMap = new Map(); messageReactsMap.set(messageInfo.reaction, usersInfoMap); } const messageReactionUsersInfoMap = messageReactsMap.get( messageInfo.reaction, ); invariant( messageReactionUsersInfoMap, 'messageReactionUsersInfoMap should be set', ); if (messageInfo.action === 'add_reaction') { messageReactionUsersInfoMap.set( messageInfo.creator.id, messageInfo.creator, ); } else { messageReactionUsersInfoMap.delete(messageInfo.creator.id); } } const targetMessageEditMap = new Map(); for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if (messageInfo.type !== messageTypes.EDIT_MESSAGE) { continue; } targetMessageEditMap.set(messageInfo.targetMessageID, messageInfo.text); } const targetMessagePinStatusMap = new Map(); // Once again, we iterate backwards to put the order of messages in // chronological order (i.e. oldest to newest) to handle pinned messages. // This is important because we want to make sure that the most recent pin // action is the one that is used to determine whether a message // is pinned or not. for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if (messageInfo.type !== messageTypes.TOGGLE_PIN) { continue; } targetMessagePinStatusMap.set( messageInfo.targetMessageID, messageInfo.action === 'pin', ); } const chatMessageItems: ChatMessageItem[] = []; let lastMessageInfo = null; for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if ( messageInfo.type === messageTypes.REACTION || messageInfo.type === messageTypes.EDIT_MESSAGE ) { continue; } let originalMessageInfo = messageInfo.type === messageTypes.SIDEBAR_SOURCE ? messageInfo.sourceMessage : messageInfo; if (isEmptyMediaMessage(originalMessageInfo)) { continue; } let hasBeenEdited = false; if ( originalMessageInfo.type === messageTypes.TEXT && originalMessageInfo.id ) { const newText = targetMessageEditMap.get(originalMessageInfo.id); if (newText !== undefined) { hasBeenEdited = true; originalMessageInfo = { ...originalMessageInfo, text: newText, }; } } let startsConversation = true; let startsCluster = true; if ( lastMessageInfo && lastMessageInfo.time + msInFiveMinutes > originalMessageInfo.time ) { startsConversation = false; if ( isComposableMessageType(lastMessageInfo.type) && isComposableMessageType(originalMessageInfo.type) && lastMessageInfo.creator.id === originalMessageInfo.creator.id ) { startsCluster = false; } } if (startsCluster && chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } const threadCreatedFromMessage = !threadTypeIsSidebar(threadInfos[threadID]?.type) && messageInfo.id ? threadInfoFromSourceMessageID[messageInfo.id] : undefined; const isPinned = !!( originalMessageInfo.id && targetMessagePinStatusMap.get(originalMessageInfo.id) ); const renderedReactions: ReactionInfo = (() => { const result: { [string]: MessageReactionInfo } = {}; let messageReactsMap; if (originalMessageInfo.id) { messageReactsMap = targetMessageReactionsMap.get( originalMessageInfo.id, ); } if (!messageReactsMap) { return result; } for (const reaction of messageReactsMap.keys()) { const reactionUsersInfoMap = messageReactsMap.get(reaction); invariant(reactionUsersInfoMap, 'reactionUsersInfoMap should be set'); if (reactionUsersInfoMap.size === 0) { continue; } const reactionUserInfos = [...reactionUsersInfoMap.values()]; const messageReactionInfo = { users: reactionUserInfos, viewerReacted: reactionUsersInfoMap.has(viewerID), }; result[reaction] = messageReactionInfo; } return result; })(); if (isComposableMessageType(originalMessageInfo.type)) { // We use these invariants instead of just checking the messageInfo.type // directly in the conditional above so that isComposableMessageType can // be the source of truth invariant( originalMessageInfo.type === messageTypes.TEXT || originalMessageInfo.type === messageTypes.IMAGES || originalMessageInfo.type === messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const localMessageInfo = messageStore.local[messageKey(originalMessageInfo)]; chatMessageItems.push({ itemType: 'message', messageInfoType: 'composable', messageInfo: originalMessageInfo, localMessageInfo, startsConversation, startsCluster, endsCluster: false, threadCreatedFromMessage, reactions: renderedReactions, hasBeenEdited, isPinned, }); } else { invariant( originalMessageInfo.type !== messageTypes.TEXT && originalMessageInfo.type !== messageTypes.IMAGES && originalMessageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const threadInfo = threadInfos[threadID]; const parentThreadInfo = threadInfo?.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; const robotext = robotextForMessageInfo( originalMessageInfo, threadInfo, parentThreadInfo, ); chatMessageItems.push({ itemType: 'message', messageInfoType: 'robotext', messageInfo: originalMessageInfo, startsConversation, startsCluster, endsCluster: false, threadCreatedFromMessage, robotext, reactions: renderedReactions, }); } lastMessageInfo = originalMessageInfo; } if (chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } chatMessageItems.reverse(); const hideSpinner = thread ? thread.startReached : threadIsPending(threadID); if (hideSpinner) { return chatMessageItems; } return [...chatMessageItems, ({ itemType: 'loader' }: ChatMessageItem)]; } const baseMessageListData = ( threadID: ?string, additionalMessages: $ReadOnlyArray, ): ((state: BaseAppState<>) => ?(ChatMessageItem[])) => createSelector( (state: BaseAppState<>) => state.messageStore, messageInfoSelector, threadInfoSelector, threadInfoFromSourceMessageIDSelector, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, ( messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, threadInfos: { +[id: string]: ThreadInfo, }, threadInfoFromSourceMessageID: { +[id: string]: ThreadInfo, }, viewerID: ?string, ): ?(ChatMessageItem[]) => { if (!threadID || !viewerID) { return null; } return createChatMessageItems( threadID, messageStore, messageInfos, threadInfos, threadInfoFromSourceMessageID, additionalMessages, viewerID, ); }, ); export type MessageListData = ?(ChatMessageItem[]); const messageListData: ( threadID: ?string, additionalMessages: $ReadOnlyArray, ) => (state: BaseAppState<>) => MessageListData = memoize2(baseMessageListData); export type UseMessageListDataArgs = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, +threadInfo: ?ThreadInfo, }; function useMessageListData({ searching, userInfoInputArray, threadInfo, }: UseMessageListDataArgs): MessageListData { const messageInfos = useSelector(messageInfoSelector); const containingThread = useSelector(state => { if ( !threadInfo || !threadTypeIsSidebar(threadInfo.type) || !threadInfo.containingThreadID ) { return null; } return state.messageStore.threads[threadInfo.containingThreadID]; }); const pendingSidebarEditMessageInfo = React.useMemo(() => { const sourceMessageID = threadInfo?.sourceMessageID; const threadMessageInfos = (containingThread?.messageIDs ?? []) .map((messageID: string) => messageInfos[messageID]) .filter(Boolean) .filter( message => message.type === messageTypes.EDIT_MESSAGE && message.targetMessageID === sourceMessageID, ); if (threadMessageInfos.length === 0) { return null; } return threadMessageInfos[0]; }, [threadInfo, containingThread, messageInfos]); const pendingSidebarSourceMessageInfo = useSelector(state => { const sourceMessageID = threadInfo?.sourceMessageID; if ( !threadInfo || !threadTypeIsSidebar(threadInfo.type) || !sourceMessageID ) { return null; } const thread = state.messageStore.threads[threadInfo.id]; const shouldSourceBeAdded = !thread || (thread.startReached && thread.messageIDs.every( id => messageInfos[id]?.type !== messageTypes.SIDEBAR_SOURCE, )); return shouldSourceBeAdded ? messageInfos[sourceMessageID] : null; }); invariant( !pendingSidebarSourceMessageInfo || pendingSidebarSourceMessageInfo.type !== messageTypes.SIDEBAR_SOURCE, 'sidebars can not be created from sidebar_source message', ); const additionalMessages = React.useMemo(() => { if (!pendingSidebarSourceMessageInfo) { return ([]: MessageInfo[]); } const result: MessageInfo[] = [pendingSidebarSourceMessageInfo]; if (pendingSidebarEditMessageInfo) { result.push(pendingSidebarEditMessageInfo); } return result; }, [pendingSidebarSourceMessageInfo, pendingSidebarEditMessageInfo]); const boundMessageListData = useSelector( messageListData(threadInfo?.id, additionalMessages), ); return React.useMemo(() => { if (searching && userInfoInputArray.length === 0) { return []; } return boundMessageListData; }, [searching, userInfoInputArray.length, boundMessageListData]); } export { messageInfoSelector, createChatThreadItem, chatListData, createChatMessageItems, messageListData, useFlattenedChatListData, useFilteredChatListData, useMessageListData, }; diff --git a/web/chat/composed-message.react.js b/web/chat/composed-message.react.js index f6d72fbca..3afa5c581 100644 --- a/web/chat/composed-message.react.js +++ b/web/chat/composed-message.react.js @@ -1,292 +1,292 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { CheckCircle as CheckCircleIcon, Circle as CircleIcon, XCircle as XCircleIcon, } from 'react-feather'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; -import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; +import { type ComposableChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { getMessageLabel } from 'lib/shared/edit-messages-utils.js'; import { assertComposableMessageType } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { getComposedMessageID } from './chat-constants.js'; import css from './chat-message-list.css'; import FailedSend from './failed-send.react.js'; import InlineEngagement from './inline-engagement.react.js'; import UserAvatar from '../avatars/user-avatar.react.js'; import CommIcon from '../comm-icon.react.js'; import { usePushUserProfileModal } from '../modals/user-profile/user-profile-utils.js'; import { useMessageTooltip } from '../tooltips/tooltip-action-utils.js'; import { tooltipPositions } from '../tooltips/tooltip-utils.js'; export type ComposedMessageID = string; const availableTooltipPositionsForViewerMessage = [ tooltipPositions.LEFT, tooltipPositions.LEFT_BOTTOM, tooltipPositions.LEFT_TOP, tooltipPositions.RIGHT, tooltipPositions.RIGHT_BOTTOM, tooltipPositions.RIGHT_TOP, tooltipPositions.BOTTOM, tooltipPositions.TOP, ]; const availableTooltipPositionsForNonViewerMessage = [ tooltipPositions.RIGHT, tooltipPositions.RIGHT_BOTTOM, tooltipPositions.RIGHT_TOP, tooltipPositions.LEFT, tooltipPositions.LEFT_BOTTOM, tooltipPositions.LEFT_TOP, tooltipPositions.BOTTOM, tooltipPositions.TOP, ]; type Props = { - +item: ChatMessageInfoItem, + +item: ComposableChatMessageInfoItem, +threadInfo: ThreadInfo, +shouldDisplayPinIndicator: boolean, +sendFailed: boolean, +children: React.Node, +fixedWidth?: boolean, +borderRadius?: number, }; const ComposedMessage: React.ComponentType = React.memo( function ComposedMessage(props) { const { item, threadInfo } = props; const { messageInfo } = item; const { id, creator } = messageInfo; const { isViewer } = creator; assertComposableMessageType(messageInfo.type); const shouldShowUsername = !isViewer && item.startsCluster; const stringForUser = useStringForUser(shouldShowUsername ? creator : null); const pushUserProfileModal = usePushUserProfileModal(creator.id); const authorName = React.useMemo(() => { if (!stringForUser) { return null; } return ( {stringForUser} ); }, [stringForUser, pushUserProfileModal]); const threadColor = threadInfo.color; const notDeliveredP2PMessages = item?.localMessageInfo?.outboundP2PMessageIDs; const { deliveryIcon, failedSendInfo } = React.useMemo(() => { if (!isViewer) { return { deliveryIcon: null, failedSendInfo: null }; } let returnedFailedSendInfo, deliveryIconSpan; let deliveryIconColor = threadColor; if ( id !== null && id !== undefined && (!notDeliveredP2PMessages || notDeliveredP2PMessages.length === 0) ) { deliveryIconSpan = ; } else if (props.sendFailed) { deliveryIconSpan = ; deliveryIconColor = 'FF0000'; returnedFailedSendInfo = ( ); } else { deliveryIconSpan = ; } const returnedDeliveryIcon = (
{deliveryIconSpan}
); return { deliveryIcon: returnedDeliveryIcon, failedSendInfo: returnedFailedSendInfo, }; }, [ isViewer, threadColor, id, notDeliveredP2PMessages, props.sendFailed, item, threadInfo, ]); const label = getMessageLabel(item.hasBeenEdited, threadInfo.id); const inlineEngagement = React.useMemo(() => { if ( !item.threadCreatedFromMessage && Object.keys(item.reactions).length === 0 && !label ) { return null; } const positioning = isViewer ? 'right' : 'left'; return (
); }, [ item.threadCreatedFromMessage, item.reactions, label, isViewer, item.messageInfo, threadInfo, ]); const avatar = React.useMemo(() => { if (!isViewer && item.endsCluster) { return (
); } else if (!isViewer) { return
; } return undefined; }, [isViewer, item.endsCluster, pushUserProfileModal, creator.id]); const shouldShowPinIcon = item.isPinned && props.shouldDisplayPinIndicator; const pinIcon = React.useMemo(() => { if (!shouldShowPinIcon) { return null; } const pinIconPositioning = isViewer ? 'left' : 'right'; const pinIconName = pinIconPositioning === 'left' ? 'pin-mirror' : 'pin'; const pinIconContainerClassName = classNames({ [css.pinIconContainer]: true, [css.pinIconLeft]: pinIconPositioning === 'left', [css.pinIconRight]: pinIconPositioning === 'right', }); return (
); }, [shouldShowPinIcon, isViewer, threadColor]); const availablePositions = isViewer ? availableTooltipPositionsForViewerMessage : availableTooltipPositionsForNonViewerMessage; const { onMouseLeave, onMouseEnter } = useMessageTooltip({ item, threadInfo, availablePositions, }); const contentClassName = React.useMemo( () => classNames({ [css.content]: true, [css.viewerContent]: isViewer, [css.nonViewerContent]: !isViewer, }), [isViewer], ); const messageBoxContainerClassName = React.useMemo( () => classNames({ [css.messageBoxContainer]: true, [css.fixedWidthMessageBoxContainer]: props.fixedWidth, }), [props.fixedWidth], ); const messageBoxClassName = React.useMemo( () => classNames({ [css.messageBox]: true, [css.fixedWidthMessageBox]: props.fixedWidth, }), [props.fixedWidth], ); const borderRadius = props.borderRadius ?? 8; const messageBoxStyle = React.useMemo( () => ({ borderTopRightRadius: isViewer && !item.startsCluster ? 0 : borderRadius, borderBottomRightRadius: isViewer && !item.endsCluster ? 0 : borderRadius, borderTopLeftRadius: !isViewer && !item.startsCluster ? 0 : borderRadius, borderBottomLeftRadius: !isViewer && !item.endsCluster ? 0 : borderRadius, }), [isViewer, item.startsCluster, item.endsCluster, borderRadius], ); const composedMessageID = getComposedMessageID(item.messageInfo); return React.useMemo( () => ( <> {authorName}
{avatar}
{pinIcon}
{props.children}
{deliveryIcon}
{failedSendInfo} {inlineEngagement} ), [ authorName, contentClassName, avatar, messageBoxContainerClassName, onMouseEnter, onMouseLeave, pinIcon, messageBoxClassName, messageBoxStyle, composedMessageID, props.children, deliveryIcon, failedSendInfo, inlineEngagement, ], ); }, ); export default ComposedMessage; diff --git a/web/chat/edit-message-provider.js b/web/chat/edit-message-provider.js index 78a9cbcb8..b8d742a3a 100644 --- a/web/chat/edit-message-provider.js +++ b/web/chat/edit-message-provider.js @@ -1,219 +1,219 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import ModalOverlay from 'lib/components/modal-overlay.react.js'; -import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; +import type { ComposableChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { EditTextMessage } from './edit-text-message.react.js'; export type ModalPosition = { +left: number, +top: number, +width: number, +height: number, }; export type EditState = { - +messageInfo: ChatMessageInfoItem, + +messageInfo: ComposableChatMessageInfoItem, +threadInfo: ThreadInfo, +editedMessageDraft: ?string, +isError: boolean, +position?: ModalPosition, +maxHeight: number, }; export type ScrollToMessageCallback = ( composedMessageID: string, callback: (maxHeight: number) => void, ) => void; type EditModalContextType = { +renderEditModal: (params: EditState) => void, +clearEditModal: () => void, +editState: ?EditState, +setDraft: string => void, +setError: boolean => void, +updatePosition: ModalPosition => void, +scrollToMessage: ScrollToMessageCallback, +addScrollToMessageListener: ScrollToMessageCallback => void, +removeScrollToMessageListener: ScrollToMessageCallback => void, }; const EditModalContext: React.Context = React.createContext({ renderEditModal: () => {}, clearEditModal: () => {}, editState: null, setDraft: () => {}, setError: () => {}, updatePosition: () => {}, scrollToMessage: () => {}, addScrollToMessageListener: () => {}, removeScrollToMessageListener: () => {}, }); type Props = { +children: React.Node, }; function EditModalProvider(props: Props): React.Node { const { children } = props; const [editState, setEditState] = React.useState(null); const [scrollToMessageCallbacks, setScrollToMessageCallbacks] = React.useState>([]); const clearEditModal = React.useCallback(() => { setEditState(null); }, []); const renderEditModal = React.useCallback((newEditState: EditState): void => { setEditState(newEditState); }, []); const modal = React.useMemo(() => { if (!editState || !editState.position) { return null; } const tooltipNode = ( ); const tooltipContainerStyle = { position: 'fixed', left: editState.position.left, top: editState.position.top, width: editState.position.width, height: editState.position.height, }; return
{tooltipNode}
; }, [editState]); const setDraft = React.useCallback( (draft: ?string) => { if (!editState) { return; } setEditState({ ...editState, editedMessageDraft: draft, }); }, [editState, setEditState], ); const setError = React.useCallback( (isError: boolean) => { invariant(editState, 'editState should be set in setError'); setEditState({ ...editState, isError, }); }, [editState, setEditState], ); const updatePosition = React.useCallback( (position: ModalPosition) => { invariant(editState, 'editState should be set in updatePosition'); setEditState({ ...editState, position, }); }, [editState, setEditState], ); const scrollToMessage: ScrollToMessageCallback = React.useCallback( (messageKey: string, callback: (maxHeight: number) => void) => { scrollToMessageCallbacks.forEach((callback2: ScrollToMessageCallback) => callback2(messageKey, callback), ); }, [scrollToMessageCallbacks], ); const addScrollToMessageListener = React.useCallback( (callback: ScrollToMessageCallback): void => { setScrollToMessageCallbacks(prevScrollToMessageCallbacks => [ ...prevScrollToMessageCallbacks, callback, ]); }, [], ); const removeScrollToMessageListener = React.useCallback( (callback: ScrollToMessageCallback) => { setScrollToMessageCallbacks(prevScrollToMessageCallbacks => prevScrollToMessageCallbacks.filter( candidate => candidate !== callback, ), ); }, [], ); const value = React.useMemo( () => ({ renderEditModal, clearEditModal: clearEditModal, editState, setDraft, setError, updatePosition, scrollToMessage, addScrollToMessageListener, removeScrollToMessageListener, }), [ renderEditModal, clearEditModal, editState, setDraft, setError, updatePosition, scrollToMessage, addScrollToMessageListener, removeScrollToMessageListener, ], ); const modalOverlay = React.useMemo(() => { if (!modal) { return null; } return ( {modal} ); }, [clearEditModal, modal]); return ( {children} {modalOverlay} ); } function useEditModalContext(): EditModalContextType { const context = React.useContext(EditModalContext); invariant(context, 'EditModalContext not found'); return context; } export { EditModalProvider, useEditModalContext }; diff --git a/web/chat/edit-text-message.react.js b/web/chat/edit-text-message.react.js index 9111a2032..191279506 100644 --- a/web/chat/edit-text-message.react.js +++ b/web/chat/edit-text-message.react.js @@ -1,196 +1,196 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useCallback } from 'react'; import { XCircle as XCircleIcon } from 'react-feather'; -import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; +import type { ComposableChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { useEditMessage } from 'lib/shared/edit-messages-utils.js'; import { trimMessage } from 'lib/shared/message-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { editBoxBottomRowHeight } from './chat-constants.js'; import ChatInputTextArea from './chat-input-text-area.react.js'; import ComposedMessage from './composed-message.react.js'; import { useEditModalContext } from './edit-message-provider.js'; import css from './edit-text-message.css'; import type { ButtonColor } from '../components/button.react.js'; import Button from '../components/button.react.js'; type Props = { - +item: ChatMessageInfoItem, + +item: ComposableChatMessageInfoItem, +threadInfo: ThreadInfo, +background: boolean, }; const cancelButtonColor: ButtonColor = { backgroundColor: 'transparent', }; const bottomRowStyle = { height: editBoxBottomRowHeight }; function EditTextMessage(props: Props): React.Node { const { background, threadInfo, item } = props; const { editState, clearEditModal, setDraft, setError, updatePosition } = useEditModalContext(); const editMessage = useEditMessage(threadInfo); const myRef = React.useRef(null); const editedMessageDraft = editState?.editedMessageDraft ?? ''; const threadColor = threadInfo.color; const saveButtonColor: ButtonColor = React.useMemo( () => ({ backgroundColor: `#${threadColor}`, }), [threadColor], ); const isMessageEmpty = React.useMemo( () => trimMessage(editedMessageDraft) === '', [editedMessageDraft], ); const isMessageEdited = React.useMemo(() => { const { messageInfo } = item; if (!messageInfo || !messageInfo.text || !editState) { return false; } if (!editedMessageDraft) { return false; } const trimmedDraft = trimMessage(editedMessageDraft); return trimmedDraft !== messageInfo.text; }, [editState, editedMessageDraft, item]); const checkAndEdit = async () => { const { id: messageInfoID } = item.messageInfo; if (isMessageEmpty) { return; } if (!isMessageEdited) { clearEditModal(); return; } if (!messageInfoID || !editState?.editedMessageDraft) { return; } try { await editMessage(messageInfoID, editState.editedMessageDraft); clearEditModal(); } catch (e) { setError(true); } }; const updateDimensions = useCallback(() => { if (!myRef.current || !background) { return; } const { left, top, width, height } = myRef.current.getBoundingClientRect(); updatePosition({ left, top, width, height, }); }, [background, updatePosition]); const preventCloseTab = React.useCallback( (event: BeforeUnloadEvent) => { if (!isMessageEdited) { return null; } event.preventDefault(); return (event.returnValue = ''); }, [isMessageEdited], ); React.useEffect(() => { if (!background) { return undefined; } window.addEventListener('resize', updateDimensions); window.addEventListener('beforeunload', preventCloseTab); return () => { window.removeEventListener('resize', updateDimensions); window.removeEventListener('beforeunload', preventCloseTab); }; }, [background, preventCloseTab, updateDimensions]); React.useEffect(() => { updateDimensions(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); let editFailed; if (editState?.isError) { editFailed = (
Edit failed.
Please try again.
); } const containerStyle = classNames(css.editMessage, { [css.backgroundEditMessage]: background, }); const maxTextAreaHeight = editState?.maxHeight; return (
{editFailed}
); } const ComposedEditTextMessage: React.ComponentType = React.memo( function ComposedEditTextMessage(props) { const { background, ...restProps } = props; return ( ); }, ); export { EditTextMessage, ComposedEditTextMessage }; diff --git a/web/chat/failed-send.react.js b/web/chat/failed-send.react.js index 7db488c53..e1de72e21 100644 --- a/web/chat/failed-send.react.js +++ b/web/chat/failed-send.react.js @@ -1,163 +1,163 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; -import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; +import { type ComposableChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { messageID } from 'lib/shared/message-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { assertComposableMessageType, type RawComposableMessageInfo, } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import css from './chat-message-list.css'; import multimediaMessageSendFailed from './multimedia-message-send-failed.js'; import textMessageSendFailed from './text-message-send-failed.js'; import Button from '../components/button.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import { useSelector } from '../redux/redux-utils.js'; type BaseProps = { - +item: ChatMessageInfoItem, + +item: ComposableChatMessageInfoItem, +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, +rawMessageInfo: RawComposableMessageInfo, +inputState: ?InputState, +parentThreadInfo: ?ThreadInfo, }; class FailedSend extends React.PureComponent { retryingText = false; retryingMedia = false; componentDidUpdate(prevProps: Props) { if ( (this.props.rawMessageInfo.type === messageTypes.IMAGES || this.props.rawMessageInfo.type === messageTypes.MULTIMEDIA) && (prevProps.rawMessageInfo.type === messageTypes.IMAGES || prevProps.rawMessageInfo.type === messageTypes.MULTIMEDIA) ) { const { inputState } = this.props; const prevInputState = prevProps.inputState; invariant( inputState && prevInputState, 'inputState should be set in FailedSend', ); const isFailed = multimediaMessageSendFailed(this.props.item, inputState); const wasFailed = multimediaMessageSendFailed( prevProps.item, prevInputState, ); const isDone = this.props.item.messageInfo.id !== null && this.props.item.messageInfo.id !== undefined; const wasDone = prevProps.item.messageInfo.id !== null && prevProps.item.messageInfo.id !== undefined; if ((isFailed && !wasFailed) || (isDone && !wasDone)) { this.retryingMedia = false; } } else if ( this.props.rawMessageInfo.type === messageTypes.TEXT && prevProps.rawMessageInfo.type === messageTypes.TEXT ) { const isFailed = textMessageSendFailed(this.props.item); const wasFailed = textMessageSendFailed(prevProps.item); const isDone = this.props.item.messageInfo.id !== null && this.props.item.messageInfo.id !== undefined; const wasDone = prevProps.item.messageInfo.id !== null && prevProps.item.messageInfo.id !== undefined; if ((isFailed && !wasFailed) || (isDone && !wasDone)) { this.retryingText = false; } } } render(): React.Node { return (
Delivery failed.
); } retrySend = () => { const { inputState } = this.props; invariant(inputState, 'inputState should be set in FailedSend'); const { rawMessageInfo } = this.props; if (rawMessageInfo.type === messageTypes.TEXT) { if (this.retryingText) { return; } this.retryingText = true; void inputState.sendTextMessage( { ...rawMessageInfo, time: Date.now(), }, this.props.threadInfo, this.props.parentThreadInfo, ); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { const { localID } = rawMessageInfo; invariant(localID, 'failed RawMessageInfo should have localID'); if (this.retryingMedia) { return; } this.retryingMedia = true; inputState.retryMultimediaMessage(localID, this.props.threadInfo); } }; } const ConnectedFailedSend: React.ComponentType = React.memo(function ConnectedFailedSend(props) { const { messageInfo } = props.item; assertComposableMessageType(messageInfo.type); const id = messageID(messageInfo); const rawMessageInfo = useSelector( state => state.messageStore.messages[id], ); assertComposableMessageType(rawMessageInfo.type); invariant( rawMessageInfo.type === messageTypes.TEXT || rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, 'FailedSend should only be used for composable message types', ); const inputState = React.useContext(InputStateContext); const { parentThreadID } = props.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); return ( ); }); export default ConnectedFailedSend; diff --git a/web/chat/message.react.js b/web/chat/message.react.js index 4b6b7ec34..2d513e93f 100644 --- a/web/chat/message.react.js +++ b/web/chat/message.react.js @@ -1,80 +1,85 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.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)}
); } const { editState } = useEditModalContext(); let message; if ( + item.messageInfoType === 'composable' && item.messageInfo.id && editState?.messageInfo.messageInfo?.id === item.messageInfo.id ) { message = ( ); - } else if (item.messageInfo.type === messageTypes.TEXT) { + } else if ( + item.messageInfoType === 'composable' && + item.messageInfo.type === messageTypes.TEXT + ) { message = ( ); } else if ( - item.messageInfo.type === messageTypes.IMAGES || - item.messageInfo.type === messageTypes.MULTIMEDIA + 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/chat/multimedia-message-send-failed.js b/web/chat/multimedia-message-send-failed.js index 87c3687f9..5bb816ac8 100644 --- a/web/chat/multimedia-message-send-failed.js +++ b/web/chat/multimedia-message-send-failed.js @@ -1,31 +1,31 @@ // @flow import invariant from 'invariant'; -import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; +import type { ComposableChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { InputState } from '../input/input-state.js'; export default function multimediaMessageSendFailed( - item: ChatMessageInfoItem, + item: ComposableChatMessageInfoItem, inputState: InputState, ): boolean { const { messageInfo } = item; if ( !messageInfo.creator.isViewer || (messageInfo.type !== messageTypes.MULTIMEDIA && messageInfo.type !== messageTypes.IMAGES) ) { return false; } const { id, localID } = messageInfo; if (id !== null && id !== undefined) { return false; } invariant(localID, 'localID should be set if serverID is not'); return !!( inputState.messageHasUploadFailure(localID) || (item.localMessageInfo && item.localMessageInfo.sendFailed) ); } diff --git a/web/chat/multimedia-message.react.js b/web/chat/multimedia-message.react.js index 963b31458..59b993bd8 100644 --- a/web/chat/multimedia-message.react.js +++ b/web/chat/multimedia-message.react.js @@ -1,111 +1,111 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { encryptedMediaBlobURI, encryptedVideoThumbnailBlobURI, } from 'lib/media/media-utils.js'; -import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; +import { type ComposableChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import css from './chat-message-list.css'; import ComposedMessage from './composed-message.react.js'; import sendFailed from './multimedia-message-send-failed.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import Multimedia from '../media/multimedia.react.js'; type BaseProps = { - +item: ChatMessageInfoItem, + +item: ComposableChatMessageInfoItem, +threadInfo: ThreadInfo, +shouldDisplayPinIndicator: boolean, }; type Props = { ...BaseProps, // withInputState +inputState: ?InputState, }; class MultimediaMessage extends React.PureComponent { render(): React.Node { const { item, inputState } = this.props; invariant( item.messageInfo.type === messageTypes.IMAGES || item.messageInfo.type === messageTypes.MULTIMEDIA, 'MultimediaMessage should only be used for multimedia messages', ); const { localID, media } = item.messageInfo; invariant(inputState, 'inputState should be set in MultimediaMessage'); const pendingUploads = localID ? inputState.assignedUploads[localID] : null; const multimedia = []; for (const singleMedia of media) { const pendingUpload = pendingUploads ? pendingUploads.find(upload => upload.localID === singleMedia.id) : null; const thumbHash = singleMedia.thumbHash ?? singleMedia.thumbnailThumbHash; let mediaSource; if (singleMedia.type === 'photo' || singleMedia.type === 'video') { const { type, uri, thumbnailURI, dimensions } = singleMedia; mediaSource = { type, uri, thumbHash, thumbnailURI, dimensions }; } else { const { type, encryptionKey, thumbnailEncryptionKey, dimensions } = singleMedia; const blobURI = encryptedMediaBlobURI(singleMedia); const thumbnailBlobURI = singleMedia.type === 'encrypted_video' ? encryptedVideoThumbnailBlobURI(singleMedia) : null; mediaSource = { type, blobURI, encryptionKey, thumbnailBlobURI, thumbnailEncryptionKey, dimensions, thumbHash, }; } multimedia.push( , ); } invariant(multimedia.length > 0, 'should be at least one multimedia...'); const content = multimedia.length > 1 ? (
{multimedia}
) : ( multimedia ); return ( 1} borderRadius={16} > {content} ); } } const ConnectedMultimediaMessage: React.ComponentType = React.memo(function ConnectedMultimediaMessage(props) { const inputState = React.useContext(InputStateContext); return ; }); export default ConnectedMultimediaMessage; diff --git a/web/chat/text-message-send-failed.js b/web/chat/text-message-send-failed.js index eba79ae2a..05fff0230 100644 --- a/web/chat/text-message-send-failed.js +++ b/web/chat/text-message-send-failed.js @@ -1,17 +1,17 @@ // @flow -import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; +import type { ComposableChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { textMessageSendFailed as sharedTextMessageSendFailed } from 'lib/shared/chat-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; export default function textMessageSendFailed( - item: ChatMessageInfoItem, + item: ComposableChatMessageInfoItem, ): boolean { const { messageInfo, localMessageInfo } = item; if (messageInfo.type !== messageTypes.TEXT) { return false; } return sharedTextMessageSendFailed(messageInfo, localMessageInfo); } diff --git a/web/chat/text-message.react.js b/web/chat/text-message.react.js index a25210569..d060005a0 100644 --- a/web/chat/text-message.react.js +++ b/web/chat/text-message.react.js @@ -1,69 +1,69 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; -import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; +import type { ComposableChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { onlyEmojiRegex } from 'lib/shared/emojis.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import css from './chat-message-list.css'; import ComposedMessage from './composed-message.react.js'; import { MessageListContext } from './message-list-types.js'; import textMessageSendFailed from './text-message-send-failed.js'; import Markdown from '../markdown/markdown.react.js'; type Props = { - +item: ChatMessageInfoItem, + +item: ComposableChatMessageInfoItem, +threadInfo: ThreadInfo, +shouldDisplayPinIndicator: boolean, }; function TextMessage(props: Props): React.Node { invariant( props.item.messageInfo.type === messageTypes.TEXT, 'TextMessage should only be used for messageTypes.TEXT', ); const { text, creator: { isViewer }, } = props.item.messageInfo; const messageStyle: { backgroundColor?: string } = {}; let darkColor = true; if (isViewer) { const threadColor = props.threadInfo.color; darkColor = colorIsDark(threadColor); messageStyle.backgroundColor = `#${threadColor}`; } const onlyEmoji = onlyEmojiRegex.test(text); const messageClassName = classNames({ [css.textMessage]: true, [css.textMessageDefaultBackground]: !isViewer, [css.normalTextMessage]: !onlyEmoji, [css.emojiOnlyTextMessage]: onlyEmoji, [css.darkTextMessage]: darkColor, [css.lightTextMessage]: !darkColor, }); const messageListContext = React.useContext(MessageListContext); invariant(messageListContext, 'DummyTextNode should have MessageListContext'); const rules = messageListContext.getTextMessageMarkdownRules(darkColor); return (
{text}
); } export default TextMessage; diff --git a/web/tooltips/tooltip-action-utils.js b/web/tooltips/tooltip-action-utils.js index 2fafe41e5..784753b4f 100644 --- a/web/tooltips/tooltip-action-utils.js +++ b/web/tooltips/tooltip-action-utils.js @@ -1,556 +1,560 @@ // @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 { 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 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 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, };