diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index 25f345824..0e89fb839 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,714 +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, + +messageInfos: $ReadOnlyArray, +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 | 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, + messageInfos: [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/lib/shared/chat-message-item-utils.js b/lib/shared/chat-message-item-utils.js index 91e528702..3ca2b8e43 100644 --- a/lib/shared/chat-message-item-utils.js +++ b/lib/shared/chat-message-item-utils.js @@ -1,82 +1,112 @@ // @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 BaseChatMessageInfoItem = + | { + +itemType: 'message', + +messageInfo: ChatMessageItemMessageInfo, + +messageInfos?: ?void, + ... + } + | { + +itemType: 'message', + +messageInfos: $ReadOnlyArray, + +messageInfo?: ?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); + if (item.messageInfo) { + return messageKey(item.messageInfo); + } + return item.messageInfos.map(messageKey).join('^'); } function chatMessageInfoItemTimestamp(item: BaseChatMessageInfoItem): string { - return longAbsoluteDate(item.messageInfo.time); + // If there's an array of messageInfos, we expect at least one, + // and the most recent should be first + const messageInfo = item.messageInfo + ? item.messageInfo + : item.messageInfos[0]; + return longAbsoluteDate(messageInfo.time); } +// If the ChatMessageInfoItem can be the target of operations like sidebar +// creation, reaction, or pinning, then this function returns the RawMessageInfo +// that would be the target. Currently, the only reason that a +// ChatMessageInfoItem can't be such a target would be if it's a combined +// RobotextChatMessageInfoItem that has multiple messageInfos. function chatMessageItemEngagementTargetMessageInfo( item: BaseChatMessageInfoItem, -): ComposableMessageInfo | RobotextMessageInfo { - return item.messageInfo; +): ?ComposableMessageInfo | RobotextMessageInfo { + if (item.messageInfo) { + return item.messageInfo; + } else if (item.messageInfos && item.messageInfos.length === 1) { + return item.messageInfos[0]; + } + return null; } function chatMessageItemHasNonViewerMessage( item: BaseChatMessageItem, viewerID: ?string, ): boolean { - return ( - item.itemType === 'message' && item.messageInfo.creator.id !== viewerID - ); + if (item.messageInfo) { + return item.messageInfo.creator.id !== viewerID; + } else if (item.messageInfos) { + return item.messageInfos.some( + messageInfo => messageInfo.creator.id !== viewerID, + ); + } + return false; } 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/edit-messages-utils.js b/lib/shared/edit-messages-utils.js index 618215b3d..2fe605dad 100644 --- a/lib/shared/edit-messages-utils.js +++ b/lib/shared/edit-messages-utils.js @@ -1,136 +1,140 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import uuid from 'uuid'; import { type OutboundDMOperationSpecification, dmOperationSpecificationTypes, } from './dm-ops/dm-op-utils.js'; import { useProcessAndSendDMOperation } from './dm-ops/process-dm-ops.js'; import { threadIsPending, useThreadHasPermission } from './thread-utils.js'; import { sendEditMessageActionTypes, useSendEditMessage, } from '../actions/message-actions.js'; import { type DMSendEditMessageOperation } from '../types/dm-ops.js'; import type { ComposableMessageInfo, RawMessageInfo, RobotextMessageInfo, } from '../types/message-types'; import { messageTypes } from '../types/message-types-enum.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { thickThreadTypes, threadTypeIsThick, } from '../types/thread-types-enum.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; function useEditMessage( threadInfo: ThreadInfo, ): (messageID: string, newText: string) => Promise { const callEditMessage = useSendEditMessage(); const dispatchActionPromise = useDispatchActionPromise(); const processAndSendDMOperation = useProcessAndSendDMOperation(); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); return React.useCallback( async (messageID, newText) => { if (threadTypeIsThick(threadInfo.type)) { invariant(viewerID, 'viewerID should be set'); const op: DMSendEditMessageOperation = { type: 'send_edit_message', threadID: threadInfo.id, creatorID: viewerID, time: Date.now(), messageID: uuid.v4(), targetMessageID: messageID, text: newText, }; const opSpecification: OutboundDMOperationSpecification = { type: dmOperationSpecificationTypes.OUTBOUND, op, recipients: { type: 'all_thread_members', threadID: threadInfo.type === thickThreadTypes.THICK_SIDEBAR && threadInfo.parentThreadID ? threadInfo.parentThreadID : threadInfo.id, }, }; await processAndSendDMOperation(opSpecification); } else { const editMessagePromise = (async () => { const result = await callEditMessage({ targetMessageID: messageID, text: newText, }); return ({ newMessageInfos: result.newMessageInfos, }: { +newMessageInfos: $ReadOnlyArray }); })(); void dispatchActionPromise( sendEditMessageActionTypes, editMessagePromise, ); await editMessagePromise; } }, [ threadInfo.type, threadInfo.id, threadInfo.parentThreadID, viewerID, processAndSendDMOperation, dispatchActionPromise, callEditMessage, ], ); } function useCanEditMessage( threadInfo: ThreadInfo, - targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo, + targetMessageInfo: ?ComposableMessageInfo | RobotextMessageInfo, ): boolean { const currentUserInfo = useSelector(state => state.currentUserInfo); const currentUserCanEditMessage = useThreadHasPermission( threadInfo, threadPermissions.EDIT_MESSAGE, ); if (!currentUserCanEditMessage) { return false; } - if (!targetMessageInfo.id || targetMessageInfo.type !== messageTypes.TEXT) { + if ( + !targetMessageInfo || + !targetMessageInfo.id || + targetMessageInfo.type !== messageTypes.TEXT + ) { return false; } if (!currentUserInfo || !currentUserInfo.id) { return false; } const currentUserId = currentUserInfo.id; const targetMessageCreatorId = targetMessageInfo.creator.id; return currentUserId === targetMessageCreatorId; } function getMessageLabel(hasBeenEdited: ?boolean, threadID: string): ?string { const isPending = threadIsPending(threadID); if (hasBeenEdited && !isPending) { return 'Edited'; } return null; } export { useCanEditMessage, useEditMessage, getMessageLabel }; diff --git a/lib/shared/reaction-utils.js b/lib/shared/reaction-utils.js index 9c016882c..83539cea8 100644 --- a/lib/shared/reaction-utils.js +++ b/lib/shared/reaction-utils.js @@ -1,113 +1,113 @@ // @flow import _sortBy from 'lodash/fp/sortBy.js'; import * as React from 'react'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import { useThreadHasPermission } from './thread-utils.js'; import { stringForUserExplicit } from './user-utils.js'; import { useENSNames } from '../hooks/ens-cache.js'; import type { ReactionInfo } from '../selectors/chat-selectors.js'; import type { ComposableMessageInfo, RobotextMessageInfo, } 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 { threadTypeIsThick } from '../types/thread-types-enum.js'; import { useSelector } from '../utils/redux-utils.js'; function useViewerAlreadySelectedMessageReactions( reactions: ReactionInfo, ): $ReadOnlyArray { return React.useMemo(() => { const alreadySelectedEmojis = []; for (const reaction in reactions) { const reactionInfo = reactions[reaction]; if (reactionInfo.viewerReacted) { alreadySelectedEmojis.push(reaction); } } return alreadySelectedEmojis; }, [reactions]); } export type MessageReactionListInfo = { +id: string, +isViewer: boolean, +reaction: string, +username: string, }; function useMessageReactionsList( reactions: ReactionInfo, ): $ReadOnlyArray { const withoutENSNames = React.useMemo(() => { const result = []; for (const reaction in reactions) { const reactionInfo = reactions[reaction]; reactionInfo.users.forEach(user => { result.push({ ...user, username: stringForUserExplicit(user), reaction, }); }); } const sortByNumReactions = (reactionInfo: MessageReactionListInfo) => { const numOfReactions = reactions[reactionInfo.reaction].users.length; return numOfReactions ? -numOfReactions : 0; }; return _sortBy( ([sortByNumReactions, 'username']: $ReadOnlyArray< ((reactionInfo: MessageReactionListInfo) => mixed) | string, >), )(result); }, [reactions]); return useENSNames(withoutENSNames); } function useCanCreateReactionFromMessage( threadInfo: ThreadInfo, - targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo, + targetMessageInfo: ?ComposableMessageInfo | RobotextMessageInfo, ): boolean { - const targetMessageCreatorRelationship = useSelector( - state => - state.userStore.userInfos[targetMessageInfo.creator.id] - ?.relationshipStatus, + const creatorID = targetMessageInfo?.creator.id; + const targetMessageCreatorRelationship = useSelector(state => + creatorID ? state.userStore.userInfos[creatorID]?.relationshipStatus : null, ); const userHasReactionPermission = useThreadHasPermission( threadInfo, threadPermissions.REACT_TO_MESSAGE, ); if (!userHasReactionPermission) { return false; } if ( + !targetMessageInfo || (!targetMessageInfo.id && !threadTypeIsThick(threadInfo.type)) || (threadInfo.sourceMessageID && threadInfo.sourceMessageID === targetMessageInfo.id) ) { return false; } return ( !targetMessageCreatorRelationship || !relationshipBlockedInEitherDirection(targetMessageCreatorRelationship) ); } export { useViewerAlreadySelectedMessageReactions, useMessageReactionsList, useCanCreateReactionFromMessage, }; diff --git a/lib/shared/sidebar-utils.js b/lib/shared/sidebar-utils.js index fc09df8f6..a0840895d 100644 --- a/lib/shared/sidebar-utils.js +++ b/lib/shared/sidebar-utils.js @@ -1,247 +1,249 @@ // @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, + messageInfo: ?ComposableMessageInfo | RobotextMessageInfo, ): boolean { - const messageCreatorUserInfo = useSelector( - state => state.userStore.userInfos[messageInfo.creator.id], + const creatorID = messageInfo?.creator.id; + const messageCreatorUserInfo = useSelector(state => + creatorID ? state.userStore.userInfos[creatorID] : null, ); const hasCreateSidebarsPermission = useThreadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); if (!hasCreateSidebarsPermission) { return false; } if ( + !messageInfo || (!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, engagementTargetMessageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } export { createUnresolvedPendingSidebar, createPendingSidebar, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, }; diff --git a/lib/utils/message-pinning-utils.js b/lib/utils/message-pinning-utils.js index 4d8100ec7..d18490edf 100644 --- a/lib/utils/message-pinning-utils.js +++ b/lib/utils/message-pinning-utils.js @@ -1,48 +1,50 @@ // @flow import { isInvalidPinSourceForThread } from '../shared/message-utils.js'; import { threadHasPermission, useThreadHasPermission, } from '../shared/thread-utils.js'; import type { MessageInfo, RawMessageInfo } from '../types/message-types.js'; import type { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import type { LegacyRawThreadInfo } from '../types/thread-types.js'; function canToggleMessagePin( messageInfo: RawMessageInfo, threadInfo: LegacyRawThreadInfo | RawThreadInfo, ): boolean { const isValidMessage = !isInvalidPinSourceForThread(messageInfo, threadInfo); const hasManagePinsPermission = threadHasPermission( threadInfo, threadPermissions.MANAGE_PINS, ); return isValidMessage && hasManagePinsPermission; } function useCanToggleMessagePin( - messageInfo: MessageInfo, + messageInfo: ?MessageInfo, threadInfo: ThreadInfo, ): boolean { - const isValidMessage = !isInvalidPinSourceForThread(messageInfo, threadInfo); const hasManagePinsPermission = useThreadHasPermission( threadInfo, threadPermissions.MANAGE_PINS, ); - - return isValidMessage && hasManagePinsPermission; + return ( + !!messageInfo && + hasManagePinsPermission && + !isInvalidPinSourceForThread(messageInfo, threadInfo) + ); } function pinnedMessageCountText(pinnedCount: number): string { const messageNoun = pinnedCount === 1 ? 'message' : 'messages'; return `${pinnedCount} pinned ${messageNoun}`; } export { canToggleMessagePin, useCanToggleMessagePin, pinnedMessageCountText }; diff --git a/native/chat/chat-item-height-measurer.react.js b/native/chat/chat-item-height-measurer.react.js index 280959291..6937bff72 100644 --- a/native/chat/chat-item-height-measurer.react.js +++ b/native/chat/chat-item-height-measurer.react.js @@ -1,234 +1,236 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js'; import { getMessageLabel } from 'lib/shared/edit-messages-utils.js'; import { getInlineEngagementSidebarText, reactionsToRawString, } from 'lib/shared/inline-engagement-utils.js'; import { messageID } from 'lib/shared/message-utils.js'; import { messageTypes, type MessageType, } from 'lib/types/message-types-enum.js'; import { entityTextToRawString } from 'lib/utils/entity-text.js'; import type { MeasurementTask } from './chat-context-provider.react.js'; import { useComposedMessageMaxWidth } from './composed-message-width.js'; import { dummyNodeForInlineEngagementHeightMeasurement } from './inline-engagement.react.js'; import { dummyNodeForRobotextMessageHeightMeasurement } from './inner-robotext-message.react.js'; import { dummyNodeForTextMessageHeightMeasurement } from './inner-text-message.react.js'; import type { NativeChatMessageItem } from './message-data.react.js'; import { MessageListContextProvider } from './message-list-types.js'; import { multimediaMessageContentSizes } from './multimedia-message-utils.js'; import NodeHeightMeasurer from '../components/node-height-measurer.react.js'; import { InputStateContext } from '../input/input-state.js'; type Props = { +measurement: MeasurementTask, }; const heightMeasurerKey = (item: NativeChatMessageItem) => { if (item.itemType !== 'message') { return null; } const { messageInfo, hasBeenEdited, threadCreatedFromMessage, reactions } = item; - if (messageInfo.type === messageTypes.TEXT) { + if (messageInfo && messageInfo.type === messageTypes.TEXT) { return JSON.stringify({ text: messageInfo.text, edited: getMessageLabel(hasBeenEdited, messageInfo.threadID), sidebar: getInlineEngagementSidebarText(threadCreatedFromMessage), reactions: reactionsToRawString(reactions), }); } else if (item.robotext) { - const { threadID } = item.messageInfo; + const { threadID } = item.messageInfos[0]; return JSON.stringify({ robotext: entityTextToRawString(item.robotext, { threadID }), sidebar: getInlineEngagementSidebarText(threadCreatedFromMessage), reactions: reactionsToRawString(reactions), }); } else if (threadCreatedFromMessage || Object.keys(reactions).length > 0) { // we enter this condition when the item is a multimedia message with an // inline engagement return JSON.stringify({ sidebar: getInlineEngagementSidebarText(threadCreatedFromMessage), reactions: reactionsToRawString(reactions), }); } return null; }; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return const heightMeasurerDummy = (item: NativeChatMessageItem) => { invariant( item.itemType === 'message', 'NodeHeightMeasurer asked for dummy for non-message item', ); const { messageInfo, hasBeenEdited, threadCreatedFromMessage, reactions } = item; - if (messageInfo.type === messageTypes.TEXT) { + if (messageInfo && messageInfo.type === messageTypes.TEXT) { const label = getMessageLabel(hasBeenEdited, messageInfo.threadID); return dummyNodeForTextMessageHeightMeasurement( messageInfo.text, label, threadCreatedFromMessage, reactions, ); } else if (item.robotext) { return dummyNodeForRobotextMessageHeightMeasurement( item.robotext, - messageInfo.threadID, + item.messageInfos[0].threadID, threadCreatedFromMessage, reactions, ); } else if (threadCreatedFromMessage || Object.keys(reactions).length > 0) { // we enter this condition when the item is a multimedia message with an // inline engagement return dummyNodeForInlineEngagementHeightMeasurement( threadCreatedFromMessage, reactions, ); } invariant( false, 'NodeHeightMeasurer asked for dummy for multimedia message with no inline engagement', ); }; function ChatItemHeightMeasurer(props: Props) { const composedMessageMaxWidth = useComposedMessageMaxWidth(); const inputState = React.useContext(InputStateContext); const inputStatePendingUploads = inputState?.pendingUploads; const { measurement } = props; const { threadInfo } = measurement; const heightMeasurerMergeItem = React.useCallback( (item: NativeChatMessageItem, height: ?number) => { if (item.itemType !== 'message') { return item; } + if (item.messageInfoType !== 'composable') { + invariant( + height !== null && height !== undefined, + 'height should be set', + ); + return { + itemType: 'message', + messageShapeType: 'robotext', + messageInfos: item.messageInfos, + threadInfo, + startsConversation: item.startsConversation, + startsCluster: item.startsCluster, + endsCluster: item.endsCluster, + threadCreatedFromMessage: item.threadCreatedFromMessage, + robotext: item.robotext, + contentHeight: height, + reactions: item.reactions, + }; + } + const { messageInfo } = item; const messageType: MessageType = messageInfo.type; invariant( messageType !== messageTypes.SIDEBAR_SOURCE, 'Sidebar source messages should be replaced by sourceMessage before being measured', ); if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; const id = messageID(messageInfo); const pendingUploads = inputStatePendingUploads?.[id]; const sizes = multimediaMessageContentSizes( messageInfo, composedMessageMaxWidth, ); return { itemType: 'message', messageShapeType: 'multimedia', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, threadCreatedFromMessage: item.threadCreatedFromMessage, pendingUploads, reactions: item.reactions, hasBeenEdited: item.hasBeenEdited, isPinned: item.isPinned, inlineEngagementHeight: height, ...sizes, }; } invariant( height !== null && height !== undefined, 'height should be set', ); if (messageInfo.type === messageTypes.TEXT) { // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; return { itemType: 'message', messageShapeType: 'text', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, threadCreatedFromMessage: item.threadCreatedFromMessage, contentHeight: height, reactions: item.reactions, hasBeenEdited: item.hasBeenEdited, isPinned: item.isPinned, }; } - invariant( - item.messageInfoType !== 'composable', + + throw new Error( 'ChatItemHeightMeasurer was handed a messageInfoType=composable, but ' + `does not know how to handle MessageType ${messageInfo.type}`, ); - invariant( - item.messageInfoType === 'robotext', - 'ChatItemHeightMeasurer was handed a messageInfoType that it does ' + - `not recognize: ${item.messageInfoType}`, - ); - return { - itemType: 'message', - messageShapeType: 'robotext', - messageInfo, - threadInfo, - startsConversation: item.startsConversation, - startsCluster: item.startsCluster, - endsCluster: item.endsCluster, - threadCreatedFromMessage: item.threadCreatedFromMessage, - robotext: item.robotext, - contentHeight: height, - reactions: item.reactions, - }; }, [composedMessageMaxWidth, inputStatePendingUploads, threadInfo], ); return ( ); } const MemoizedChatItemHeightMeasurer: React.ComponentType = React.memo(ChatItemHeightMeasurer); export default MemoizedChatItemHeightMeasurer; diff --git a/native/chat/inner-robotext-message.react.js b/native/chat/inner-robotext-message.react.js index b25f42b1a..e5b2a0ea0 100644 --- a/native/chat/inner-robotext-message.react.js +++ b/native/chat/inner-robotext-message.react.js @@ -1,178 +1,178 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, TouchableWithoutFeedback, View } from 'react-native'; import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type EntityText, entityTextToRawString, entityTextToReact, useResolvedEntityText, } from 'lib/utils/entity-text.js'; import { DummyInlineEngagementNode } from './inline-engagement.react.js'; import { useNavigateToThread } from './message-list-types.js'; import Markdown from '../markdown/markdown.react.js'; import { inlineMarkdownRules } from '../markdown/rules.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useOverlayStyles } from '../themes/colors.js'; import type { ChatRobotextMessageInfoItemWithHeight } from '../types/chat-types.js'; import { useNavigateToUserProfileBottomSheet } from '../user-profile/user-profile-utils.js'; function dummyNodeForRobotextMessageHeightMeasurement( robotext: EntityText, threadID: string, sidebarInfo: ?ThreadInfo, reactions: ReactionInfo, ): React.Element { return ( {entityTextToRawString(robotext, { threadID })} ); } type InnerRobotextMessageProps = { +item: ChatRobotextMessageInfoItemWithHeight, +onPress: () => void, +onLongPress?: () => void, }; function InnerRobotextMessage(props: InnerRobotextMessageProps): React.Node { const { item, onLongPress, onPress } = props; const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const styles = useOverlayStyles(unboundStyles); - const { messageInfo, robotext } = item; - const { threadID } = messageInfo; + const { messageInfos, robotext } = item; + const { threadID } = messageInfos[0]; const resolvedRobotext = useResolvedEntityText(robotext); invariant( resolvedRobotext, 'useResolvedEntityText only returns falsey when passed falsey', ); const textParts = React.useMemo(() => { const darkColor = activeTheme === 'dark'; return entityTextToReact(resolvedRobotext, threadID, { renderText: ({ text }) => ( {text} ), renderThread: ({ id, name }) => , renderUser: ({ userID, usernameText }) => ( ), renderColor: ({ hex }) => , renderFarcasterUser: ({ farcasterUsername }) => ( {farcasterUsername} ), }); }, [resolvedRobotext, activeTheme, threadID, styles.robotext]); return ( {textParts} ); } type ThreadEntityProps = { +id: string, +name: string, }; function ThreadEntity(props: ThreadEntityProps) { const threadID = props.id; const threadInfo = useSelector(state => threadInfoSelector(state)[threadID]); const styles = useOverlayStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const onPressThread = React.useCallback(() => { invariant(threadInfo, 'onPressThread should have threadInfo'); navigateToThread({ threadInfo }); }, [threadInfo, navigateToThread]); if (!threadInfo) { return {props.name}; } return ( {props.name} ); } type UserEntityProps = { +userID: string, +usernameText: string, }; function UserEntity(props: UserEntityProps) { const { userID, usernameText } = props; const styles = useOverlayStyles(unboundStyles); const navigateToUserProfileBottomSheet = useNavigateToUserProfileBottomSheet(); const onPressUser = React.useCallback( () => navigateToUserProfileBottomSheet(userID), [navigateToUserProfileBottomSheet, userID], ); return ( {usernameText} ); } function ColorEntity(props: { +color: string }) { const colorStyle = { color: props.color }; return {props.color}; } const unboundStyles = { link: { color: 'link', }, robotextContainer: { paddingTop: 6, paddingBottom: 11, paddingHorizontal: 24, }, robotext: { color: 'listForegroundSecondaryLabel', fontFamily: 'Arial', fontSize: 15, textAlign: 'center', }, dummyRobotext: { fontFamily: 'Arial', fontSize: 15, textAlign: 'center', }, }; const MemoizedInnerRobotextMessage: React.ComponentType = React.memo(InnerRobotextMessage); export { dummyNodeForRobotextMessageHeightMeasurement, MemoizedInnerRobotextMessage as InnerRobotextMessage, }; diff --git a/native/chat/robotext-message.react.js b/native/chat/robotext-message.react.js index 17c515df9..9c8d088e7 100644 --- a/native/chat/robotext-message.react.js +++ b/native/chat/robotext-message.react.js @@ -1,243 +1,246 @@ // @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)) { + if ( + engagementTargetMessageInfo && + 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, 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/native/chat/sidebar-navigation.js b/native/chat/sidebar-navigation.js index 95d2058e9..950f99b9d 100644 --- a/native/chat/sidebar-navigation.js +++ b/native/chat/sidebar-navigation.js @@ -1,139 +1,150 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ENSCacheContext } from 'lib/components/ens-cache-provider.react.js'; import { NeynarClientContext } from 'lib/components/neynar-client-provider.react.js'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { createPendingSidebar, createUnresolvedPendingSidebar, } from 'lib/shared/sidebar-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ChatMentionCandidates } from 'lib/types/thread-types.js'; import type { LoggedInUserInfo } from 'lib/types/user-types.js'; import type { GetENSNames } from 'lib/utils/ens-helpers.js'; import type { GetFCNames } from 'lib/utils/farcaster-helpers.js'; import { ChatContext } from './chat-context.js'; import { useNavigateToThread } from './message-list-types.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; type GetUnresolvedSidebarThreadInfoInput = { +sourceMessage: ChatMessageInfoItemWithHeight, +loggedInUserInfo: ?LoggedInUserInfo, +chatMentionCandidates: ChatMentionCandidates, }; function getUnresolvedSidebarThreadInfo( input: GetUnresolvedSidebarThreadInfoInput, ): ?ThreadInfo { const { sourceMessage, loggedInUserInfo, chatMentionCandidates } = input; const threadCreatedFromMessage = sourceMessage.threadCreatedFromMessage; if (threadCreatedFromMessage) { return threadCreatedFromMessage; } if (!loggedInUserInfo) { return null; } const { messageInfo, threadInfo } = sourceMessage; + if (!messageInfo) { + return null; + } + return createUnresolvedPendingSidebar({ sourceMessageInfo: messageInfo, parentThreadInfo: threadInfo, loggedInUserInfo, markdownRules: getDefaultTextMessageRules(chatMentionCandidates) .simpleMarkdownRules, }); } type GetSidebarThreadInfoInput = { ...GetUnresolvedSidebarThreadInfoInput, +getENSNames: ?GetENSNames, +getFCNames: ?GetFCNames, }; async function getSidebarThreadInfo( input: GetSidebarThreadInfoInput, ): Promise { const { sourceMessage, loggedInUserInfo, getENSNames, getFCNames, chatMentionCandidates, } = input; const threadCreatedFromMessage = sourceMessage.threadCreatedFromMessage; if (threadCreatedFromMessage) { return threadCreatedFromMessage; } if (!loggedInUserInfo) { return null; } const { messageInfo, threadInfo } = sourceMessage; + if (!messageInfo) { + return null; + } + return await createPendingSidebar({ sourceMessageInfo: messageInfo, parentThreadInfo: threadInfo, loggedInUserInfo, markdownRules: getDefaultTextMessageRules(chatMentionCandidates) .simpleMarkdownRules, getENSNames, getFCNames, }); } function useNavigateToSidebar( item: ChatMessageInfoItemWithHeight, ): () => mixed { const loggedInUserInfo = useLoggedInUserInfo(); const navigateToThread = useNavigateToThread(); const { getENSNames } = React.useContext(ENSCacheContext); const chatMentionCandidates = useThreadChatMentionCandidates(item.threadInfo); const neynarClientContext = React.useContext(NeynarClientContext); const getFCNames = neynarClientContext?.getFCNames; return React.useCallback(async () => { const threadInfo = await getSidebarThreadInfo({ sourceMessage: item, loggedInUserInfo, getENSNames, getFCNames, chatMentionCandidates, }); invariant(threadInfo, 'threadInfo should be set'); navigateToThread({ threadInfo }); }, [ item, loggedInUserInfo, getENSNames, getFCNames, chatMentionCandidates, navigateToThread, ]); } function useAnimatedNavigateToSidebar( item: ChatMessageInfoItemWithHeight, ): () => void { const chatContext = React.useContext(ChatContext); const setSidebarSourceID = chatContext?.setCurrentTransitionSidebarSourceID; const navigateToSidebar = useNavigateToSidebar(item); - const messageID = item.messageInfo.id; + const messageID = item.messageInfo?.id; return React.useCallback(() => { + if (!messageID) { + return; + } setSidebarSourceID && setSidebarSourceID(messageID); navigateToSidebar(); }, [setSidebarSourceID, messageID, navigateToSidebar]); } export { getUnresolvedSidebarThreadInfo, useNavigateToSidebar, useAnimatedNavigateToSidebar, }; diff --git a/native/chat/utils.js b/native/chat/utils.js index 4b7efe49b..557cf1d7a 100644 --- a/native/chat/utils.js +++ b/native/chat/utils.js @@ -1,430 +1,431 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import Animated from 'react-native-reanimated'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { viewerIsMember } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { clusterEndHeight } from './chat-constants.js'; import { ChatContext, useHeightMeasurer } from './chat-context.js'; import { failedSendHeight } from './failed-send.react.js'; import { useNativeMessageListData } from './message-data.react.js'; import { authorNameHeight } from './message-header.react.js'; import { multimediaMessageItemHeight } from './multimedia-message-utils.js'; import { getUnresolvedSidebarThreadInfo } from './sidebar-navigation.js'; import textMessageSendFailed from './text-message-send-failed.js'; import { timestampHeight } from './timestamp.react.js'; import { KeyboardContext } from '../keyboard/keyboard-state.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import { MultimediaMessageTooltipModalRouteName, RobotextMessageTooltipModalRouteName, TextMessageTooltipModalRouteName, } from '../navigation/route-names.js'; import type { ChatMessageInfoItemWithHeight, ChatMessageItemWithHeight, ChatTextMessageInfoItemWithHeight, } from '../types/chat-types.js'; import type { LayoutCoordinates, VerticalBounds, } from '../types/layout-types.js'; import type { AnimatedViewStyle } from '../types/styles.js'; const { Node, Extrapolate, interpolateNode, interpolateColors, block, call, eq, cond, sub, } = Animated; function textMessageItemHeight( item: ChatTextMessageInfoItemWithHeight, ): number { const { messageInfo, contentHeight, startsCluster, endsCluster } = item; const { isViewer } = messageInfo.creator; let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage if (!isViewer && startsCluster) { height += authorNameHeight; } if (endsCluster) { height += clusterEndHeight; } if (textMessageSendFailed(item)) { height += failedSendHeight; } return height; } function messageItemHeight(item: ChatMessageInfoItemWithHeight): number { let height = 0; if (item.messageShapeType === 'text') { height += textMessageItemHeight(item); } else if (item.messageShapeType === 'multimedia') { height += multimediaMessageItemHeight(item); } else { height += item.contentHeight; } if (item.startsConversation) { height += timestampHeight; } return height; } function chatMessageItemHeight(item: ChatMessageItemWithHeight): number { if (item.itemType === 'loader') { return 56; } return messageItemHeight(item); } function useMessageTargetParameters( sourceMessage: ChatMessageInfoItemWithHeight, initialCoordinates: LayoutCoordinates, messageListVerticalBounds: VerticalBounds, currentInputBarHeight: number, targetInputBarHeight: number, sidebarThreadInfo: ?ThreadInfo, ): { +position: number, +color: string, } { const messageListData = useNativeMessageListData({ searching: false, userInfoInputArray: [], threadInfo: sidebarThreadInfo, }); const [messagesWithHeight, setMessagesWithHeight] = React.useState>(null); const measureMessages = useHeightMeasurer(); React.useEffect(() => { if (messageListData) { measureMessages( messageListData, sidebarThreadInfo, setMessagesWithHeight, ); } }, [measureMessages, messageListData, sidebarThreadInfo]); const sourceMessageID = sourceMessage.messageInfo?.id; const targetDistanceFromBottom = React.useMemo(() => { if (!messagesWithHeight) { return 0; } let offset = 0; for (const message of messagesWithHeight) { offset += chatMessageItemHeight(message); if (message.messageInfo && message.messageInfo.id === sourceMessageID) { return offset; } } return ( messageListVerticalBounds.height + chatMessageItemHeight(sourceMessage) ); }, [ messageListVerticalBounds.height, messagesWithHeight, sourceMessage, sourceMessageID, ]); if (!sidebarThreadInfo) { return { position: 0, color: sourceMessage.threadInfo.color, }; } - const authorNameComponentHeight = sourceMessage.messageInfo.creator.isViewer - ? 0 - : authorNameHeight; + const authorNameComponentHeight = + !sourceMessage.messageInfo || sourceMessage.messageInfo.creator.isViewer + ? 0 + : authorNameHeight; const currentDistanceFromBottom = messageListVerticalBounds.height + messageListVerticalBounds.y - initialCoordinates.y + timestampHeight + authorNameComponentHeight + currentInputBarHeight; return { position: targetDistanceFromBottom + targetInputBarHeight - currentDistanceFromBottom, color: sidebarThreadInfo.color, }; } type AnimatedMessageArgs = { +sourceMessage: ChatMessageInfoItemWithHeight, +initialCoordinates: LayoutCoordinates, +messageListVerticalBounds: VerticalBounds, +progress: Node, +targetInputBarHeight: ?number, }; function useAnimatedMessageTooltipButton({ sourceMessage, initialCoordinates, messageListVerticalBounds, progress, targetInputBarHeight, }: AnimatedMessageArgs): { +style: AnimatedViewStyle, +threadColorOverride: ?Node, +isThreadColorDarkOverride: ?boolean, } { const chatContext = React.useContext(ChatContext); invariant(chatContext, 'chatContext should be set'); const { currentTransitionSidebarSourceID, setCurrentTransitionSidebarSourceID, chatInputBarHeights, sidebarAnimationType, setSidebarAnimationType, } = chatContext; const loggedInUserInfo = useLoggedInUserInfo(); const chatMentionCandidates = useThreadChatMentionCandidates( sourceMessage.threadInfo, ); const sidebarThreadInfo = React.useMemo( () => getUnresolvedSidebarThreadInfo({ sourceMessage, loggedInUserInfo, chatMentionCandidates, }), [sourceMessage, loggedInUserInfo, chatMentionCandidates], ); const currentInputBarHeight = chatInputBarHeights.get(sourceMessage.threadInfo.id) ?? 0; const keyboardState = React.useContext(KeyboardContext); const newSidebarAnimationType = !currentInputBarHeight || !targetInputBarHeight || keyboardState?.keyboardShowing || !viewerIsMember(sidebarThreadInfo) ? 'fade_source_message' : 'move_source_message'; React.useEffect(() => { setSidebarAnimationType(newSidebarAnimationType); }, [setSidebarAnimationType, newSidebarAnimationType]); const { position: targetPosition, color: targetColor } = useMessageTargetParameters( sourceMessage, initialCoordinates, messageListVerticalBounds, currentInputBarHeight, targetInputBarHeight ?? currentInputBarHeight, sidebarThreadInfo, ); React.useEffect(() => { return () => setCurrentTransitionSidebarSourceID(null); }, [setCurrentTransitionSidebarSourceID]); const bottom = React.useMemo( () => interpolateNode(progress, { inputRange: [0.3, 1], outputRange: [targetPosition, 0], extrapolate: Extrapolate.CLAMP, }), [progress, targetPosition], ); const [isThreadColorDarkOverride, setThreadColorDarkOverride] = React.useState(null); const setThreadColorBrightness = React.useCallback(() => { const isSourceThreadDark = colorIsDark(sourceMessage.threadInfo.color); const isTargetThreadDark = colorIsDark(targetColor); if (isSourceThreadDark !== isTargetThreadDark) { setThreadColorDarkOverride(isTargetThreadDark); } }, [sourceMessage.threadInfo.color, targetColor]); const threadColorOverride = React.useMemo(() => { if ( sourceMessage.messageShapeType !== 'text' || !currentTransitionSidebarSourceID ) { return null; } return block([ cond(eq(progress, 1), call([], setThreadColorBrightness)), interpolateColors(progress, { inputRange: [0, 1], outputColorRange: [ `#${targetColor}`, `#${sourceMessage.threadInfo.color}`, ], }), ]); }, [ currentTransitionSidebarSourceID, progress, setThreadColorBrightness, sourceMessage.messageShapeType, sourceMessage.threadInfo.color, targetColor, ]); const messageContainerStyle = React.useMemo(() => { return { bottom: currentTransitionSidebarSourceID ? bottom : 0, opacity: currentTransitionSidebarSourceID && sidebarAnimationType === 'fade_source_message' ? 0 : 1, }; }, [bottom, currentTransitionSidebarSourceID, sidebarAnimationType]); return { style: messageContainerStyle, threadColorOverride, isThreadColorDarkOverride, }; } function getMessageTooltipKey(item: ChatMessageInfoItemWithHeight): string { return `tooltip|${chatMessageItemKey(item)}`; } function isMessageTooltipKey(key: string): boolean { return key.startsWith('tooltip|'); } function useOverlayPosition(item: ChatMessageInfoItemWithHeight) { const overlayContext = React.useContext(OverlayContext); invariant(overlayContext, 'should be set'); for (const overlay of overlayContext.visibleOverlays) { if ( (overlay.routeName === MultimediaMessageTooltipModalRouteName || overlay.routeName === TextMessageTooltipModalRouteName || overlay.routeName === RobotextMessageTooltipModalRouteName) && overlay.routeKey === getMessageTooltipKey(item) ) { return overlay.position; } } return undefined; } function useContentAndHeaderOpacity( item: ChatMessageInfoItemWithHeight, ): number | Node { const overlayPosition = useOverlayPosition(item); const chatContext = React.useContext(ChatContext); return React.useMemo( () => overlayPosition && chatContext?.sidebarAnimationType === 'move_source_message' ? sub( 1, interpolateNode(overlayPosition, { inputRange: [0.05, 0.06], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }), ) : 1, [chatContext?.sidebarAnimationType, overlayPosition], ); } function useDeliveryIconOpacity( item: ChatMessageInfoItemWithHeight, ): number | Node { const overlayPosition = useOverlayPosition(item); const chatContext = React.useContext(ChatContext); return React.useMemo(() => { if ( !overlayPosition || !chatContext?.currentTransitionSidebarSourceID || chatContext?.sidebarAnimationType === 'fade_source_message' ) { return 1; } return interpolateNode(overlayPosition, { inputRange: [0.05, 0.06, 1], outputRange: [1, 0, 0], extrapolate: Extrapolate.CLAMP, }); }, [ chatContext?.currentTransitionSidebarSourceID, chatContext?.sidebarAnimationType, overlayPosition, ]); } function modifyItemForResultScreen( item: ChatMessageInfoItemWithHeight, ): ChatMessageInfoItemWithHeight { if (item.messageShapeType === 'robotext') { return item; } if (item.messageShapeType === 'multimedia') { return { ...item, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; } return { ...item, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; } export { chatMessageItemHeight, useAnimatedMessageTooltipButton, messageItemHeight, getMessageTooltipKey, isMessageTooltipKey, useContentAndHeaderOpacity, useDeliveryIconOpacity, modifyItemForResultScreen, }; diff --git a/native/search/message-search.react.js b/native/search/message-search.react.js index dc893fd19..68307b9d2 100644 --- a/native/search/message-search.react.js +++ b/native/search/message-search.react.js @@ -1,245 +1,258 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { messageListData } from 'lib/selectors/chat-selectors.js'; import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js'; import { createMessageInfo } from 'lib/shared/message-utils.js'; import { filterChatMessageInfosForSearch, useSearchMessages, } from 'lib/shared/search-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import SearchFooter from './search-footer.react.js'; import { MessageSearchContext } from './search-provider.react.js'; import { useHeightMeasurer } from '../chat/chat-context.js'; import type { ChatNavigationProp } from '../chat/chat.react.js'; import { MessageListContextProvider } from '../chat/message-list-types.js'; import MessageResult from '../chat/message-result.react.js'; import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; export type MessageSearchParams = { +threadInfo: ThreadInfo, }; export type MessageSearchProps = { +navigation: ChatNavigationProp<'MessageSearch'>, +route: NavigationRoute<'MessageSearch'>, }; function MessageSearch(props: MessageSearchProps): React.Node { const searchContext = React.useContext(MessageSearchContext); invariant(searchContext, 'searchContext should be set'); const { query, clearQuery } = searchContext; const { threadInfo } = props.route.params; React.useEffect(() => { return props.navigation.addListener('beforeRemove', clearQuery); }, [props.navigation, clearQuery]); const [lastID, setLastID] = React.useState(); const [lastTimestamp, setLastTimestamp] = React.useState(); const [searchResults, setSearchResults] = React.useState< $ReadOnlyArray, >([]); const [endReached, setEndReached] = React.useState(false); const appendSearchResults = React.useCallback( ( newMessages: $ReadOnlyArray, end: boolean, queryID: number, ) => { if (queryID !== queryIDRef.current) { return; } setSearchResults(oldMessages => [...oldMessages, ...newMessages]); setEndReached(end); }, [], ); const searchMessages = useSearchMessages(); const queryIDRef = React.useRef(0); React.useEffect(() => { setSearchResults([]); setLastID(undefined); setLastTimestamp(undefined); setEndReached(false); }, [query, searchMessages]); React.useEffect(() => { queryIDRef.current += 1; searchMessages( query, threadInfo.id, appendSearchResults, queryIDRef.current, lastTimestamp, lastID, ); }, [ appendSearchResults, lastID, query, searchMessages, threadInfo.id, lastTimestamp, ]); const userInfos = useSelector(state => state.userStore.userInfos); const translatedSearchResults = React.useMemo(() => { const threadInfos = { [threadInfo.id]: threadInfo }; return searchResults .map(rawMessageInfo => createMessageInfo(rawMessageInfo, null, userInfos, threadInfos), ) .filter(Boolean); }, [searchResults, threadInfo, userInfos]); const chatMessageInfos = useSelector( messageListData(threadInfo.id, translatedSearchResults), ); const filteredChatMessageInfos = React.useMemo(() => { const result = filterChatMessageInfosForSearch( chatMessageInfos, translatedSearchResults, ); if (result && !endReached) { return [...result, { itemType: 'loader' }]; } return result; }, [chatMessageInfos, endReached, translatedSearchResults]); const [measuredMessages, setMeasuredMessages] = React.useState< $ReadOnlyArray, >([]); const measureMessages = useHeightMeasurer(); const measureCallback = React.useCallback( (listDataWithHeights: $ReadOnlyArray) => { setMeasuredMessages(listDataWithHeights); }, [setMeasuredMessages], ); React.useEffect(() => { measureMessages(filteredChatMessageInfos, threadInfo, measureCallback); }, [filteredChatMessageInfos, measureCallback, measureMessages, threadInfo]); const [messageVerticalBounds, setMessageVerticalBounds] = React.useState(); const scrollViewContainerRef = React.useRef>(); const onLayout = React.useCallback(() => { scrollViewContainerRef.current?.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } setMessageVerticalBounds({ height, y: pageY }); }, ); }, []); const renderItem = React.useCallback( ({ item }: { +item: ChatMessageItemWithHeight, ... }) => { if (item.itemType === 'loader') { return ; } return ( ); }, [messageVerticalBounds, props.navigation, props.route, threadInfo], ); const footer = React.useMemo(() => { if (query === '') { return ; } if (!endReached) { return null; } if (measuredMessages.length > 0) { return ; } const text = 'No results. Please try using different keywords to refine your search'; return ; }, [query, endReached, measuredMessages.length]); const onEndOfLoadedMessagesReached = React.useCallback(() => { if (endReached) { return; } const oldest = oldestMessage(measuredMessages); setLastID(oldest?.id); setLastTimestamp(oldest?.time); }, [endReached, measuredMessages]); const styles = useStyles(unboundStyles); return ( ); } function oldestMessage(data: $ReadOnlyArray) { for (let i = data.length - 1; i >= 0; i--) { - if (data[i].itemType === 'message' && data[i].messageInfo.id) { - return data[i].messageInfo; + const item = data[i]; + if (item.itemType !== 'message') { + continue; + } + if (item.messageShapeType !== 'robotext') { + if (item.messageInfo.id) { + return item.messageInfo; + } + continue; + } + for (let j = item.messageInfos.length - 1; j >= 0; j--) { + const messageInfo = item.messageInfos[j]; + if (messageInfo.id) { + return messageInfo; + } } } return undefined; } const unboundStyles = { content: { height: '100%', backgroundColor: 'panelBackground', }, }; export default MessageSearch; diff --git a/native/types/chat-types.js b/native/types/chat-types.js index d5c7a847a..d1499f983 100644 --- a/native/types/chat-types.js +++ b/native/types/chat-types.js @@ -1,82 +1,82 @@ // @flow import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import type { LocalMessageInfo, MultimediaMessageInfo, RobotextMessageInfo, } from 'lib/types/message-types.js'; import type { TextMessageInfo } from 'lib/types/messages/text.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { EntityText } from 'lib/utils/entity-text.js'; import type { MessagePendingUploads } from '../input/input-state.js'; export type ChatRobotextMessageInfoItemWithHeight = { +itemType: 'message', +messageShapeType: 'robotext', - +messageInfo: RobotextMessageInfo, + +messageInfos: $ReadOnlyArray, +threadInfo: ThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, +robotext: EntityText, +threadCreatedFromMessage: ?ThreadInfo, +contentHeight: number, +reactions: ReactionInfo, }; export type ChatTextMessageInfoItemWithHeight = { +itemType: 'message', +messageShapeType: 'text', +messageInfo: TextMessageInfo, +localMessageInfo: ?LocalMessageInfo, +threadInfo: ThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, +contentHeight: number, +threadCreatedFromMessage: ?ThreadInfo, +reactions: ReactionInfo, +hasBeenEdited: ?boolean, +isPinned: ?boolean, }; // We "measure" the contentHeight of a multimedia message using the media // dimensions. This means for multimedia messages we only need to actually // measure the inline engagement node export type MultimediaContentSizes = { +imageHeight: number, +contentHeight: number, +contentWidth: number, }; export type ChatMultimediaMessageInfoItem = { ...MultimediaContentSizes, +itemType: 'message', +messageShapeType: 'multimedia', +messageInfo: MultimediaMessageInfo, +localMessageInfo: ?LocalMessageInfo, +threadInfo: ThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, +threadCreatedFromMessage: ?ThreadInfo, +pendingUploads: ?MessagePendingUploads, +reactions: ReactionInfo, +hasBeenEdited: ?boolean, +isPinned: ?boolean, +inlineEngagementHeight: ?number, }; export type ChatComposedMessageInfoItemWithHeight = | ChatTextMessageInfoItemWithHeight | ChatMultimediaMessageInfoItem; export type ChatMessageInfoItemWithHeight = | ChatRobotextMessageInfoItemWithHeight | ChatTextMessageInfoItemWithHeight | ChatMultimediaMessageInfoItem; export type ChatMessageItemWithHeight = | { itemType: 'loader' } | ChatMessageInfoItemWithHeight; diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js index 9d81ad539..d5ae24a90 100644 --- a/web/chat/chat-message-list.react.js +++ b/web/chat/chat-message-list.react.js @@ -1,460 +1,461 @@ // @flow import classNames from 'classnames'; import { detect as detectBrowser } from 'detect-browser'; import invariant from 'invariant'; import _debounce from 'lodash/debounce.js'; import * as React from 'react'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { type ChatMessageItem, useMessageListData, } from 'lib/selectors/chat-selectors.js'; import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js'; import { useFetchMessages } from 'lib/shared/message-utils.js'; import { threadIsPending, threadOtherMembers, } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypeIsPersonal } from 'lib/types/thread-types-enum.js'; import { defaultMaxTextAreaHeight, editBoxHeight } from './chat-constants.js'; import css from './chat-message-list.css'; import type { ScrollToMessageCallback } from './edit-message-provider.js'; import { useEditModalContext } from './edit-message-provider.js'; import { MessageListContext } from './message-list-types.js'; import Message from './message.react.js'; import RelationshipPrompt from './relationship-prompt/relationship-prompt.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import LoadingIndicator from '../loading-indicator.react.js'; import { useTextMessageRulesFunc } from '../markdown/rules.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useTooltipContext } from '../tooltips/tooltip-provider.js'; const browser = detectBrowser(); const supportsReverseFlex = !browser || browser.name !== 'firefox' || parseInt(browser.version) >= 81; // Margin between the top of the maximum height edit box // and the top of the container const editBoxTopMargin = 10; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, +activeChatThreadID: ?string, +messageListData: ?$ReadOnlyArray, +startReached: boolean, +inputState: ?InputState, +clearTooltip: () => mixed, +isEditState: boolean, +addScrollToMessageListener: ScrollToMessageCallback => mixed, +removeScrollToMessageListener: ScrollToMessageCallback => mixed, +viewerID: ?string, +fetchMessages: () => Promise, }; type Snapshot = { +scrollTop: number, +scrollHeight: number, }; type State = { +scrollingEndCallback: ?() => mixed, }; class ChatMessageList extends React.PureComponent { container: ?HTMLDivElement; messageContainer: ?HTMLDivElement; loadingFromScroll = false; constructor(props: Props) { super(props); this.state = { scrollingEndCallback: null, }; } componentDidMount() { this.scrollToBottom(); this.props.addScrollToMessageListener(this.scrollToMessage); } componentWillUnmount() { this.props.removeScrollToMessageListener(this.scrollToMessage); } getSnapshotBeforeUpdate(prevProps: Props): ?Snapshot { if ( ChatMessageList.hasNewMessage(this.props, prevProps) && this.messageContainer ) { const { scrollTop, scrollHeight } = this.messageContainer; return { scrollTop, scrollHeight }; } return null; } static hasNewMessage(props: Props, prevProps: Props): boolean { const { messageListData } = props; if (!messageListData || messageListData.length === 0) { return false; } const prevMessageListData = prevProps.messageListData; if (!prevMessageListData || prevMessageListData.length === 0) { return true; } return ( chatMessageItemKey(prevMessageListData[0]) !== chatMessageItemKey(messageListData[0]) ); } componentDidUpdate(prevProps: Props, prevState: State, snapshot: ?Snapshot) { const { messageListData } = this.props; const prevMessageListData = prevProps.messageListData; const { messageContainer } = this; if ( messageContainer && prevMessageListData !== messageListData && messageContainer.scrollTop === 0 ) { this.onScroll(); } // We'll scroll to the bottom if the user was already scrolled to the bottom // before the new message, or if the new message was composed locally const hasNewMessage = ChatMessageList.hasNewMessage(this.props, prevProps); if ( this.props.activeChatThreadID !== prevProps.activeChatThreadID || (hasNewMessage && messageListData && messageListData[0].itemType === 'message' && + messageListData[0].messageInfoType === 'composable' && messageListData[0].messageInfo.localID) || (hasNewMessage && snapshot && Math.abs(snapshot.scrollTop) <= 1 && !this.props.isEditState) ) { this.scrollToBottom(); } else if (hasNewMessage && messageContainer && snapshot) { const { scrollTop, scrollHeight } = messageContainer; if ( scrollHeight > snapshot.scrollHeight && scrollTop === snapshot.scrollTop ) { const newHeight = scrollHeight - snapshot.scrollHeight; const newScrollTop = Math.abs(scrollTop) + newHeight; if (supportsReverseFlex) { messageContainer.scrollTop = -1 * newScrollTop; } else { messageContainer.scrollTop = newScrollTop; } } } } scrollToBottom() { if (this.messageContainer) { this.messageContainer.scrollTop = 0; } } renderItem = (item: ChatMessageItem): React.Node => { if (item.itemType === 'loader') { return (
); } const { threadInfo } = this.props; invariant(threadInfo, 'ThreadInfo should be set if messageListData is'); return ( ); }; scrollingEndCallbackWrapper = ( composedMessageID: string, callback: (maxHeight: number) => mixed, ): (() => mixed) => { return () => { const maxHeight = this.getMaxEditTextAreaHeight(composedMessageID); callback(maxHeight); }; }; scrollToMessage = ( composedMessageID: string, callback: (maxHeight: number) => mixed, ) => { const element = document.getElementById(composedMessageID); if (!element) { return; } const scrollingEndCallback = this.scrollingEndCallbackWrapper( composedMessageID, callback, ); if (!this.willMessageEditWindowOverflow(composedMessageID)) { scrollingEndCallback(); return; } this.setState( { scrollingEndCallback, }, () => { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); // It covers the case when browser decide not to scroll to the message // because it's already in the view. // In this case, the 'scroll' event won't be triggered, // so we need to call the callback manually. this.debounceEditModeAfterScrollToMessage(); }, ); }; getMaxEditTextAreaHeight = (composedMessageID: string): number => { const { messageContainer } = this; if (!messageContainer) { return defaultMaxTextAreaHeight; } const messageElement = document.getElementById(composedMessageID); if (!messageElement) { console.log(`couldn't find the message element`); return defaultMaxTextAreaHeight; } const msgPos = messageElement.getBoundingClientRect(); const containerPos = messageContainer.getBoundingClientRect(); const messageBottom = msgPos.bottom; const containerTop = containerPos.top; const maxHeight = messageBottom - containerTop - editBoxHeight - editBoxTopMargin; return maxHeight; }; willMessageEditWindowOverflow(composedMessageID: string): boolean { const { messageContainer } = this; if (!messageContainer) { return false; } const messageElement = document.getElementById(composedMessageID); if (!messageElement) { console.log(`couldn't find the message element`); return false; } const msgPos = messageElement.getBoundingClientRect(); const containerPos = messageContainer.getBoundingClientRect(); const containerTop = containerPos.top; const containerBottom = containerPos.bottom; const availableTextAreaHeight = (containerBottom - containerTop) / 2 - editBoxHeight; const messageHeight = msgPos.height; const expectedMinimumHeight = Math.min( defaultMaxTextAreaHeight, availableTextAreaHeight, ); const offset = Math.max( 0, expectedMinimumHeight + editBoxHeight + editBoxTopMargin - messageHeight, ); const messageTop = msgPos.top - offset; const messageBottom = msgPos.bottom; return messageBottom > containerBottom || messageTop < containerTop; } render(): React.Node { const { messageListData, threadInfo, inputState, isEditState } = this.props; if (!messageListData) { return
; } invariant(inputState, 'InputState should be set'); const messages = messageListData.map(this.renderItem); let relationshipPrompt = null; if (threadTypeIsPersonal(threadInfo.type)) { const otherMembers = threadOtherMembers( threadInfo.members, this.props.viewerID, ); let pendingPersonalThreadUserInfo; if (otherMembers.length === 1) { const otherMember = otherMembers[0]; pendingPersonalThreadUserInfo = { id: otherMember.id, username: otherMember.username, }; } relationshipPrompt = ( ); } const messageContainerStyle = classNames({ [css.disableAnchor]: this.state.scrollingEndCallback !== null || isEditState, [css.messageContainer]: true, [css.mirroredMessageContainer]: !supportsReverseFlex, }); return (
{relationshipPrompt}
{messages}
); } messageContainerRef = (messageContainer: ?HTMLDivElement) => { this.messageContainer = messageContainer; // In case we already have all the most recent messages, // but they're not enough void this.possiblyLoadMoreMessages(); if (messageContainer) { messageContainer.addEventListener('scroll', this.onScroll); } }; onScroll = () => { if (!this.messageContainer) { return; } this.props.clearTooltip(); void this.possiblyLoadMoreMessages(); this.debounceEditModeAfterScrollToMessage(); }; debounceEditModeAfterScrollToMessage: () => void = _debounce(() => { if (this.state.scrollingEndCallback) { this.state.scrollingEndCallback(); } this.setState({ scrollingEndCallback: null }); }, 100); async possiblyLoadMoreMessages() { if (!this.messageContainer) { return; } const { scrollTop, scrollHeight, clientHeight } = this.messageContainer; if ( this.props.startReached || Math.abs(scrollTop) + clientHeight + 55 < scrollHeight ) { return; } if (this.loadingFromScroll) { return; } this.loadingFromScroll = true; try { await this.props.fetchMessages(); } finally { this.loadingFromScroll = false; } } } const ConnectedChatMessageList: React.ComponentType = React.memo(function ConnectedChatMessageList( props: BaseProps, ): React.Node { const { threadInfo } = props; const messageListData = useMessageListData({ threadInfo, searching: false, userInfoInputArray: [], }); const startReached = !!useSelector(state => { const activeID = threadInfo.id; if (!activeID) { return null; } if (threadIsPending(activeID)) { return true; } const threadMessageInfo = state.messageStore.threads[activeID]; if (!threadMessageInfo) { return null; } return threadMessageInfo.startReached; }); const fetchMessages = useFetchMessages(threadInfo); const inputState = React.useContext(InputStateContext); const { clearTooltip } = useTooltipContext(); const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const getTextMessageMarkdownRules = useTextMessageRulesFunc( threadInfo, chatMentionCandidates, ); const messageListContext = React.useMemo(() => { if (!getTextMessageMarkdownRules) { return undefined; } return { getTextMessageMarkdownRules }; }, [getTextMessageMarkdownRules]); const { editState, addScrollToMessageListener, removeScrollToMessageListener, } = useEditModalContext(); const isEditState = editState !== null; const viewerID = useSelector(state => state.currentUserInfo?.id); return ( ); }); export default ConnectedChatMessageList; diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js index 92c055090..6516fb417 100644 --- a/web/chat/robotext-message.react.js +++ b/web/chat/robotext-message.react.js @@ -1,165 +1,168 @@ // @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); + const engagementTargetMessageInfo = + chatMessageItemEngagementTargetMessageInfo(item); + if ( + engagementTargetMessageInfo && + (threadCreatedFromMessage || Object.keys(reactions).length > 0) + ) { inlineEngagement = (
); } - const { messageInfo, robotext } = item; - const { threadID } = messageInfo; + const { messageInfos, robotext } = item; + const { threadID } = messageInfos[0]; 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/components/message-result.react.js b/web/components/message-result.react.js index 309e3ad2e..35d2a441e 100644 --- a/web/components/message-result.react.js +++ b/web/components/message-result.react.js @@ -1,69 +1,71 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { chatMessageInfoItemTimestamp } from 'lib/shared/chat-message-item-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import css from './message-result.css'; import { MessageListContext } from '../chat/message-list-types.js'; import Message from '../chat/message.react.js'; import { useTextMessageRulesFunc } from '../markdown/rules.react.js'; type MessageResultProps = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +scrollable: boolean, }; function MessageResult(props: MessageResultProps): React.Node { const { item, threadInfo, scrollable } = props; const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const getTextMessageMarkdownRules = useTextMessageRulesFunc( threadInfo, chatMentionCandidates, ); const messageListContext = React.useMemo(() => { if (!getTextMessageMarkdownRules) { return undefined; } return { getTextMessageMarkdownRules }; }, [getTextMessageMarkdownRules]); const shouldShowUsername = !item.startsConversation && !item.startsCluster; const username = useStringForUser( - shouldShowUsername ? item.messageInfo.creator : null, + shouldShowUsername && item.messageInfoType === 'composable' + ? item.messageInfo.creator + : null, ); const messageContainerClassNames = classNames({ [css.messageContainer]: true, [css.messageContainerOverflow]: scrollable, }); return (
{username}
{chatMessageInfoItemTimestamp(item)}
); } export default MessageResult; diff --git a/web/selectors/thread-selectors.js b/web/selectors/thread-selectors.js index 01b40c04b..eecf258be 100644 --- a/web/selectors/thread-selectors.js +++ b/web/selectors/thread-selectors.js @@ -1,163 +1,163 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { createSelector } from 'reselect'; import { ENSCacheContext } from 'lib/components/ens-cache-provider.react.js'; import { NeynarClientContext } from 'lib/components/neynar-client-provider.react.js'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { createPendingSidebar } from 'lib/shared/sidebar-utils.js'; import { threadInHomeChatList } from 'lib/shared/thread-utils.js'; import type { ComposableMessageInfo, RobotextMessageInfo, } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { RawThreadInfos } from 'lib/types/thread-types.js'; import { values } from 'lib/utils/objects.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import type { AppState } from '../redux/redux-setup.js'; import { useSelector } from '../redux/redux-utils.js'; function useOnClickThread( thread: ?ThreadInfo, ): (event: SyntheticEvent) => void { const dispatch = useDispatch(); return React.useCallback( (event: SyntheticEvent) => { invariant( thread?.id, 'useOnClickThread should be called with threadID set', ); event.preventDefault(); const { id: threadID } = thread; let payload; if (threadID.includes('pending')) { payload = { chatMode: 'view', activeChatThreadID: threadID, pendingThread: thread, tab: 'chat', }; } else { payload = { chatMode: 'view', activeChatThreadID: threadID, tab: 'chat', }; } dispatch({ type: updateNavInfoActionType, payload }); }, [dispatch, thread], ); } function useThreadIsActive(threadID: string): boolean { return useSelector(state => threadID === state.navInfo.activeChatThreadID); } function useOnClickPendingSidebar( - messageInfo: ComposableMessageInfo | RobotextMessageInfo, + messageInfo: ?ComposableMessageInfo | RobotextMessageInfo, threadInfo: ThreadInfo, ): (event: SyntheticEvent) => mixed { const dispatch = useDispatch(); const loggedInUserInfo = useLoggedInUserInfo(); const { getENSNames } = React.useContext(ENSCacheContext); const neynarClientContext = React.useContext(NeynarClientContext); const getFCNames = neynarClientContext?.getFCNames; const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); return React.useCallback( async (event: SyntheticEvent) => { event.preventDefault(); - if (!loggedInUserInfo) { + if (!loggedInUserInfo || !messageInfo) { return; } const pendingSidebarInfo = await createPendingSidebar({ sourceMessageInfo: messageInfo, parentThreadInfo: threadInfo, loggedInUserInfo, markdownRules: getDefaultTextMessageRules(chatMentionCandidates) .simpleMarkdownRules, getENSNames, getFCNames, }); dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: pendingSidebarInfo.id, pendingThread: pendingSidebarInfo, }, }); }, [ loggedInUserInfo, chatMentionCandidates, threadInfo, messageInfo, getENSNames, getFCNames, dispatch, ], ); } function useOnClickNewThread(): (event: SyntheticEvent) => void { const dispatch = useDispatch(); return React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'create', selectedUserList: [], }, }); }, [dispatch], ); } function useDrawerSelectedThreadID(): ?string { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const pickedCommunityID = useSelector( state => state.communityPickerStore.calendar, ); const inCalendar = useSelector(state => state.navInfo.tab === 'calendar'); return inCalendar ? pickedCommunityID : activeChatThreadID; } const unreadCountInSelectedCommunity: (state: AppState) => number = createSelector( (state: AppState) => state.threadStore.threadInfos, (state: AppState) => state.communityPickerStore.chat, (threadInfos: RawThreadInfos, communityID: ?string): number => values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread && (!communityID || communityID === threadInfo.community), ).length, ); export { useOnClickThread, useThreadIsActive, useOnClickPendingSidebar, useOnClickNewThread, useDrawerSelectedThreadID, unreadCountInSelectedCommunity, }; diff --git a/web/tooltips/tooltip-action-utils.js b/web/tooltips/tooltip-action-utils.js index 7e1a8cd22..33b7dcd5f 100644 --- a/web/tooltips/tooltip-action-utils.js +++ b/web/tooltips/tooltip-action-utils.js @@ -1,565 +1,579 @@ // @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, 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, ); + + let messageInfo; + if ( + item.messageInfoType === 'composable' && + item.messageInfo.type === messageTypes.TEXT && + currentUserIsVoiced + ) { + messageInfo = item.messageInfo; + } + return React.useMemo(() => { - if (item.messageInfo.type !== messageTypes.TEXT || !currentUserIsVoiced) { + if (!messageInfo) { 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, - ]); + }, [popModal, addReply, messageInfo]); } const copiedMessageDurationMs = 2000; function useMessageCopyAction( item: ChatMessageInfoItem, ): ?MessageTooltipAction { - const { messageInfo } = item; - const [successful, setSuccessful] = useResettingState( false, copiedMessageDurationMs, ); + let messageInfo; + if ( + item.messageInfoType === 'composable' && + item.messageInfo.type === messageTypes.TEXT + ) { + messageInfo = item.messageInfo; + } + + const messageText = messageInfo?.text; return React.useMemo(() => { - if (messageInfo.type !== messageTypes.TEXT) { + if (!messageText) { return null; } const buttonContent = ; const onClick = async () => { try { - await navigator.clipboard.writeText(messageInfo.text); + await navigator.clipboard.writeText(messageText); setSuccessful(true); } catch (e) { setSuccessful(false); } }; return { actionButtonContent: buttonContent, onClick, label: successful ? 'Copied!' : 'Copy', }; - }, [messageInfo.text, messageInfo.type, setSuccessful, successful]); + }, [messageText, 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 { isPinned } = item; 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!', ); + invariant( + messageInfo && messageInfo.type === messageTypes.TEXT, + 'canEditMessage should only be true for text 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, };