diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index d98773098..5014784cc 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,610 +1,611 @@ // @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 { useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { threadInfoSelector, sidebarInfoSelector, threadInfoFromSourceMessageIDSelector, } from './thread-selectors.js'; import { messageKey, robotextForMessageInfo, createMessageInfo, getMostRecentNonLocalMessageID, sortMessageInfoList, } from '../shared/message-utils.js'; import { threadIsPending, threadIsTopLevel, threadInChatList, } from '../shared/thread-utils.js'; import { type MessageInfo, type MessageStore, type ComposableMessageInfo, type RobotextMessageInfo, type LocalMessageInfo, messageTypes, isComposableMessageType, } from '../types/message-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { type ThreadInfo, type RawThreadInfo, type SidebarInfo, maxReadSidebars, maxUnreadSidebars, threadTypes, } from '../types/thread-types.js'; import type { UserInfo, AccountUserInfo, RelativeUserInfo, } 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'; 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 = []; for (const sidebar of allSidebarItems) { if (sidebarItems.length >= maxUnreadSidebars) { break; } else if (sidebar.threadInfo.currentUser.unread) { sidebarItems.push(sidebar); } else if ( sidebar.lastUpdatedTime > threeDaysAgo && numReadSidebarsToShow > 0 ) { sidebarItems.push(sidebar); numReadSidebarsToShow--; } } const numReadButRecentSidebars = allSidebarItems.filter( sidebar => !sidebar.threadInfo.currentUser.unread && sidebar.lastUpdatedTime > threeDaysAgo, ).length; if ( sidebarItems.length < numUnreadSidebars + numReadButRecentSidebars || (sidebarItems.length < allSidebarItems.length && sidebarItems.length > 0) ) { sidebarItems.push({ type: 'seeMore', unread: numUnreadSidebars > maxUnreadSidebars, }); } if (sidebarItems.length !== 0) { sidebarItems.push({ type: 'spacer', }); } return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo, mostRecentNonLocalMessage, lastUpdatedTime, lastUpdatedTimeIncludingSidebars, sidebars: sidebarItems, }; } const chatListData: (state: BaseAppState<*>) => $ReadOnlyArray = createSelector( threadInfoSelector, (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, sidebarInfoSelector, ( threadInfos: { +[id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, sidebarInfos: { +[id: string]: $ReadOnlyArray }, ): $ReadOnlyArray => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, threadIsTopLevel, ), ); function useFlattenedChatListData(): $ReadOnlyArray { return useFilteredChatListData(threadInChatList); } function useFilteredChatListData( filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean, ): $ReadOnlyArray { const threadInfos = useSelector(threadInfoSelector); const messageInfos = useSelector(messageInfoSelector); const sidebarInfos = useSelector(sidebarInfoSelector); const messageStore = useSelector(state => state.messageStore); return React.useMemo( () => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, filterFunction, ), [messageInfos, messageStore, sidebarInfos, filterFunction, threadInfos], ); } function getChatThreadItems( threadInfos: { +[id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, sidebarInfos: { +[id: string]: $ReadOnlyArray }, filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean, ): $ReadOnlyArray { return _flow( _filter(filterFunction), _map((threadInfo: ThreadInfo): ChatThreadItem => createChatThreadItem( threadInfo, messageStore, messageInfos, sidebarInfos[threadInfo.id], ), ), _orderBy('lastUpdatedTimeIncludingSidebars')('desc'), )(threadInfos); } export type RobotextChatMessageInfoItem = { +itemType: 'message', +messageInfoType: 'robotext', +messageInfo: RobotextMessageInfo, +startsConversation: boolean, +startsCluster: boolean, endsCluster: boolean, +robotext: EntityText, +threadCreatedFromMessage: ?ThreadInfo, - +reactions: $ReadOnlyMap, + +reactions: ReactionInfo, }; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem | { +itemType: 'message', +messageInfoType: 'composable', +messageInfo: ComposableMessageInfo, +localMessageInfo: ?LocalMessageInfo, +startsConversation: boolean, +startsCluster: boolean, endsCluster: boolean, +threadCreatedFromMessage: ?ThreadInfo, - +reactions: $ReadOnlyMap, + +reactions: ReactionInfo, }; export type ChatMessageItem = { itemType: 'loader' } | ChatMessageInfoItem; -export type MessageReactionInfo = { + +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 chatMessageItems = []; let lastMessageInfo = null; for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if (messageInfo.type === messageTypes.REACTION) { continue; } const originalMessageInfo = messageInfo.type === messageTypes.SIDEBAR_SOURCE ? messageInfo.sourceMessage : messageInfo; if (isEmptyMediaMessage(originalMessageInfo)) { continue; } 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 = messageInfo.id && threadInfos[threadID]?.type !== threadTypes.SIDEBAR ? threadInfoFromSourceMessageID[messageInfo.id] : undefined; - const renderedReactions: $ReadOnlyMap = - (() => { - const result = new Map(); + const renderedReactions: ReactionInfo = (() => { + const result = {}; - let messageReactsMap; - if (originalMessageInfo.id) { - messageReactsMap = targetMessageReactionsMap.get( - originalMessageInfo.id, - ); - } + let messageReactsMap; + if (originalMessageInfo.id) { + messageReactsMap = targetMessageReactionsMap.get( + originalMessageInfo.id, + ); + } - if (!messageReactsMap) { - return result; - } + if (!messageReactsMap) { + return result; + } - for (const reaction of messageReactsMap.keys()) { - const reactionUsersInfoMap = messageReactsMap.get(reaction); - invariant(reactionUsersInfoMap, 'reactionUsersInfoMap should be set'); + for (const reaction of messageReactsMap.keys()) { + const reactionUsersInfoMap = messageReactsMap.get(reaction); + invariant(reactionUsersInfoMap, 'reactionUsersInfoMap should be set'); - if (reactionUsersInfoMap.size === 0) { - continue; - } + if (reactionUsersInfoMap.size === 0) { + continue; + } - const reactionUserInfos = [...reactionUsersInfoMap.values()]; + const reactionUserInfos = [...reactionUsersInfoMap.values()]; - const messageReactionInfo = { - users: reactionUserInfos, - viewerReacted: reactionUsersInfoMap.has(viewerID), - }; + const messageReactionInfo = { + users: reactionUserInfos, + viewerReacted: reactionUsersInfoMap.has(viewerID), + }; - result.set(reaction, messageReactionInfo); - } + result[reaction] = messageReactionInfo; + } - return result; - })(); + 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, }); } else { invariant( originalMessageInfo.type !== messageTypes.TEXT && originalMessageInfo.type !== messageTypes.IMAGES && originalMessageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const robotext = robotextForMessageInfo( originalMessageInfo, threadInfos[threadID], ); chatMessageItems.push({ itemType: 'message', messageInfoType: 'robotext', messageInfo: originalMessageInfo, startsConversation, startsCluster, endsCluster: false, threadCreatedFromMessage, robotext, reactions: renderedReactions, }); } lastMessageInfo = originalMessageInfo; } if (chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } chatMessageItems.reverse(); const hideSpinner = thread ? thread.startReached : threadIsPending(threadID); if (hideSpinner) { return chatMessageItems; } return [...chatMessageItems, ({ itemType: 'loader' }: ChatMessageItem)]; } const baseMessageListData = ( threadID: ?string, additionalMessages: $ReadOnlyArray, ) => 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, ); }, ); type MessageListData = ?(ChatMessageItem[]); const messageListData: ( threadID: ?string, additionalMessages: $ReadOnlyArray, ) => (state: BaseAppState<*>) => MessageListData = memoize2(baseMessageListData); type UseMessageListDataArgs = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, +threadInfo: ?ThreadInfo, }; function useMessageListData({ searching, userInfoInputArray, threadInfo, }: UseMessageListDataArgs): MessageListData { const pendingSidebarSourceMessageInfo = useSelector(state => { const sourceMessageID = threadInfo?.sourceMessageID; if ( !threadInfo || threadInfo.type !== threadTypes.SIDEBAR || !sourceMessageID ) { return null; } const thread = state.messageStore.threads[threadInfo.id]; const messageInfos = messageInfoSelector(state); 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( () => pendingSidebarSourceMessageInfo ? [pendingSidebarSourceMessageInfo] : [], [pendingSidebarSourceMessageInfo], ); 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/reaction-utils.js b/lib/shared/reaction-utils.js index 3e0b5d325..fd4becb73 100644 --- a/lib/shared/reaction-utils.js +++ b/lib/shared/reaction-utils.js @@ -1,109 +1,107 @@ // @flow -import invariant from 'invariant'; import _sortBy from 'lodash/fp/sortBy.js'; import * as React from 'react'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import { threadHasPermission } from './thread-utils.js'; import { stringForUserExplicit } from './user-utils.js'; import { useENSNames } from '../hooks/ens-cache.js'; -import type { MessageReactionInfo } from '../selectors/chat-selectors.js'; +import type { ReactionInfo } from '../selectors/chat-selectors.js'; import type { RobotextMessageInfo, ComposableMessageInfo, } from '../types/message-types.js'; import { threadPermissions, type ThreadInfo } from '../types/thread-types.js'; import { useSelector } from '../utils/redux-utils.js'; -function stringForReactionList( - reactions: $ReadOnlyMap, -): string { +function stringForReactionList(reactions: ReactionInfo): string { const reactionText = []; - for (const reaction of reactions.keys()) { - const reactionInfo = reactions.get(reaction); - invariant(reactionInfo, 'reactionInfo should be set'); + for (const reaction in reactions) { + const reactionInfo = reactions[reaction]; reactionText.push(reaction); const { length: numberOfReacts } = reactionInfo.users; if (numberOfReacts <= 1) { continue; } reactionText.push(numberOfReacts > 9 ? '9+' : numberOfReacts.toString()); } return reactionText.join(' '); } type MessageReactionListInfo = { +id: string, +isViewer: boolean, +reaction: string, +username: string, }; function useMessageReactionsList( - reactions: $ReadOnlyMap, + reactions: ReactionInfo, ): $ReadOnlyArray { const withoutENSNames = React.useMemo(() => { const result = []; - for (const [reaction, reactionInfo] of reactions) { + 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.get(reactionInfo.reaction)?.users.length; + 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, ): boolean { const targetMessageCreatorRelationship = useSelector( state => state.userStore.userInfos[targetMessageInfo.creator.id] ?.relationshipStatus, ); if ( !targetMessageInfo.id || threadInfo.sourceMessageID === targetMessageInfo.id ) { return false; } const creatorRelationshipHasBlock = targetMessageCreatorRelationship && relationshipBlockedInEitherDirection(targetMessageCreatorRelationship); const hasPermission = threadHasPermission( threadInfo, threadPermissions.VOICED, ); return hasPermission && !creatorRelationshipHasBlock; } export { stringForReactionList, useMessageReactionsList, useCanCreateReactionFromMessage, }; diff --git a/lib/shared/reaction-utils.test.js b/lib/shared/reaction-utils.test.js index d110e3ce2..afc362408 100644 --- a/lib/shared/reaction-utils.test.js +++ b/lib/shared/reaction-utils.test.js @@ -1,156 +1,158 @@ // @flow import { stringForReactionList } from './reaction-utils.js'; -import type { MessageReactionInfo } from '../selectors/chat-selectors.js'; - -describe( - 'stringForReactionList(' + - 'reactions: $ReadOnlyMap)', - () => { - it( - 'should return (👍 3) for a message with three user likes' + - ' including the viewer', - () => { - const messageLikesUsers = [ - { id: '83810', isViewer: true, username: 'ginsu' }, - { id: '86622', isViewer: false, username: 'ashoat' }, - { id: '83889', isViewer: false, username: 'atul' }, - ]; - const messageLikesInfo = { - users: messageLikesUsers, - viewerReacted: true, - }; - - const reactionsMap = new Map(); - reactionsMap.set('👍', messageLikesInfo); - - expect(stringForReactionList(reactionsMap)).toBe('👍 3'); - }, - ); - - it( - 'should return (👍 3) for a message with three user likes' + - ' not including the viewer', - () => { - const messageLikesUsers = [ - { id: '83810', isViewer: false, username: 'ginsu' }, - { id: '86622', isViewer: false, username: 'ashoat' }, - { id: '83889', isViewer: false, username: 'atul' }, - ]; - const messageLikesInfo = { - users: messageLikesUsers, - viewerReacted: false, - }; - - const reactionsMap = new Map(); - reactionsMap.set('👍', messageLikesInfo); - - expect(stringForReactionList(reactionsMap)).toBe('👍 3'); - }, - ); - - it( - 'should return (👍) for a message with one user like' + - ' including the viewer', - () => { - const messageLikesUsers = [ - { id: '83810', isViewer: false, username: 'ginsu' }, - ]; - const messageLikesInfo = { - users: messageLikesUsers, - viewerReacted: true, - }; - - const reactionsMap = new Map(); - reactionsMap.set('👍', messageLikesInfo); - - expect(stringForReactionList(reactionsMap)).toBe('👍'); - }, - ); - - it( - 'should return (👍) for a message with one user like' + - ' not including the viewer', - () => { - const messageLikesUsers = [ - { id: '83810', isViewer: false, username: 'ashoat' }, - ]; - const messageLikesInfo = { - users: messageLikesUsers, - viewerReacted: false, - }; - - const reactionsMap = new Map(); - reactionsMap.set('👍', messageLikesInfo); - - expect(stringForReactionList(reactionsMap)).toBe('👍'); - }, - ); - - it('should return an empty string for a message no reactions', () => { - const reactionsMap = new Map(); - - expect(stringForReactionList(reactionsMap)).toBe(''); - }); - - it( - 'should return (👍 😆 3) for a message with one like not including' + - ' the viewer and three laugh reactions including the viewer', - () => { - const messageLikesUsers = [ - { id: '83810', isViewer: false, username: 'varun' }, - ]; - const messageLikesInfo = { - users: messageLikesUsers, - viewerReacted: false, - }; - - const messageLaughsUsers = [ - { id: '12345', isViewer: true, username: 'ginsu' }, - { id: '67890', isViewer: false, username: 'ashoat' }, - { id: '83889', isViewer: false, username: 'atul' }, - ]; - const messageLaughsInfo = { - users: messageLaughsUsers, - viewerReacted: true, - }; - - const reactionsMap = new Map(); - reactionsMap.set('👍', messageLikesInfo); - reactionsMap.set('😆', messageLaughsInfo); - - expect(stringForReactionList(reactionsMap)).toBe('👍 😆 3'); - }, - ); - - it( - 'should return (👍 9+) for a message with 12 user likes' + - ' not including the viewer', - () => { - const messageLikesUsers = [ - { id: '86622', isViewer: false, username: 'ginsu' }, - { id: '12345', isViewer: false, username: 'ashoat' }, - { id: '67890', isViewer: false, username: 'atul' }, - { id: '83889', isViewer: false, username: 'varun' }, - { id: '49203', isViewer: false, username: 'tomek' }, - { id: '83029', isViewer: false, username: 'max' }, - { id: '72902', isViewer: false, username: 'jon' }, - { id: '49022', isViewer: false, username: 'mark' }, - { id: '48902', isViewer: false, username: 'kamil' }, - { id: '80922', isViewer: false, username: 'marcin' }, - { id: '12890', isViewer: false, username: 'inka' }, - { id: '67891', isViewer: false, username: 'przemek' }, - ]; - const messageLikesInfo = { - users: messageLikesUsers, - viewerReacted: false, - }; - - const reactionsMap = new Map(); - reactionsMap.set('👍', messageLikesInfo); - - expect(stringForReactionList(reactionsMap)).toBe('👍 9+'); - }, - ); - }, -); +import type { ReactionInfo } from '../selectors/chat-selectors.js'; + +describe('stringForReactionList(reactions: ReactionInfo)', () => { + it( + 'should return (👍 3) for a message with three user likes' + + ' including the viewer', + () => { + const messageLikesUsers = [ + { id: '83810', isViewer: true, username: 'ginsu' }, + { id: '86622', isViewer: false, username: 'ashoat' }, + { id: '83889', isViewer: false, username: 'atul' }, + ]; + const messageLikesInfo = { + users: messageLikesUsers, + viewerReacted: true, + }; + + const reactions: ReactionInfo = { + '👍': messageLikesInfo, + }; + + expect(stringForReactionList(reactions)).toBe('👍 3'); + }, + ); + + it( + 'should return (👍 3) for a message with three user likes' + + ' not including the viewer', + () => { + const messageLikesUsers = [ + { id: '83810', isViewer: false, username: 'ginsu' }, + { id: '86622', isViewer: false, username: 'ashoat' }, + { id: '83889', isViewer: false, username: 'atul' }, + ]; + const messageLikesInfo = { + users: messageLikesUsers, + viewerReacted: false, + }; + + const reactions: ReactionInfo = { + '👍': messageLikesInfo, + }; + + expect(stringForReactionList(reactions)).toBe('👍 3'); + }, + ); + + it( + 'should return (👍) for a message with one user like' + + ' including the viewer', + () => { + const messageLikesUsers = [ + { id: '83810', isViewer: false, username: 'ginsu' }, + ]; + const messageLikesInfo = { + users: messageLikesUsers, + viewerReacted: true, + }; + + const reactions: ReactionInfo = { + '👍': messageLikesInfo, + }; + + expect(stringForReactionList(reactions)).toBe('👍'); + }, + ); + + it( + 'should return (👍) for a message with one user like' + + ' not including the viewer', + () => { + const messageLikesUsers = [ + { id: '83810', isViewer: false, username: 'ashoat' }, + ]; + const messageLikesInfo = { + users: messageLikesUsers, + viewerReacted: false, + }; + + const reactions: ReactionInfo = { + '👍': messageLikesInfo, + }; + + expect(stringForReactionList(reactions)).toBe('👍'); + }, + ); + + it('should return an empty string for a message no reactions', () => { + const reactions: ReactionInfo = {}; + + expect(stringForReactionList(reactions)).toBe(''); + }); + + it( + 'should return (👍 😆 3) for a message with one like not including' + + ' the viewer and three laugh reactions including the viewer', + () => { + const messageLikesUsers = [ + { id: '83810', isViewer: false, username: 'varun' }, + ]; + const messageLikesInfo = { + users: messageLikesUsers, + viewerReacted: false, + }; + + const messageLaughsUsers = [ + { id: '12345', isViewer: true, username: 'ginsu' }, + { id: '67890', isViewer: false, username: 'ashoat' }, + { id: '83889', isViewer: false, username: 'atul' }, + ]; + const messageLaughsInfo = { + users: messageLaughsUsers, + viewerReacted: true, + }; + + const reactions: ReactionInfo = { + '👍': messageLikesInfo, + '😆': messageLaughsInfo, + }; + + expect(stringForReactionList(reactions)).toBe('👍 😆 3'); + }, + ); + + it( + 'should return (👍 9+) for a message with 12 user likes' + + ' not including the viewer', + () => { + const messageLikesUsers = [ + { id: '86622', isViewer: false, username: 'ginsu' }, + { id: '12345', isViewer: false, username: 'ashoat' }, + { id: '67890', isViewer: false, username: 'atul' }, + { id: '83889', isViewer: false, username: 'varun' }, + { id: '49203', isViewer: false, username: 'tomek' }, + { id: '83029', isViewer: false, username: 'max' }, + { id: '72902', isViewer: false, username: 'jon' }, + { id: '49022', isViewer: false, username: 'mark' }, + { id: '48902', isViewer: false, username: 'kamil' }, + { id: '80922', isViewer: false, username: 'marcin' }, + { id: '12890', isViewer: false, username: 'inka' }, + { id: '67891', isViewer: false, username: 'przemek' }, + ]; + const messageLikesInfo = { + users: messageLikesUsers, + viewerReacted: false, + }; + + const reactions: ReactionInfo = { + '👍': messageLikesInfo, + }; + + expect(stringForReactionList(reactions)).toBe('👍 9+'); + }, + ); +}); diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js index 27f9bccdc..a0883c443 100644 --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -1,246 +1,249 @@ // @flow import Icon from '@expo/vector-icons/Feather.js'; import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import Animated from 'react-native-reanimated'; import { createMessageReply } from 'lib/shared/message-utils.js'; import { assertComposableMessageType } from 'lib/types/message-types.js'; import { clusterEndHeight, inlineEngagementStyle, inlineEngagementLeftStyle, inlineEngagementRightStyle, composedMessageStyle, } from './chat-constants.js'; import { useComposedMessageMaxWidth } from './composed-message-width.js'; import { FailedSend } from './failed-send.react.js'; import { InlineEngagement } from './inline-engagement.react.js'; import { MessageHeader } from './message-header.react.js'; import { useNavigateToSidebar } from './sidebar-navigation.js'; import SwipeableMessage from './swipeable-message.react.js'; import { useContentAndHeaderOpacity, useDeliveryIconOpacity } from './utils.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import { type Colors, useColors } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; import { type AnimatedStyleObj, AnimatedView } from '../types/styles.js'; /* eslint-disable import/no-named-as-default-member */ const { Node } = Animated; /* eslint-enable import/no-named-as-default-member */ type SwipeOptions = 'reply' | 'sidebar' | 'both' | 'none'; type BaseProps = { ...React.ElementConfig, +item: ChatMessageInfoItemWithHeight, +sendFailed: boolean, +focused: boolean, +swipeOptions: SwipeOptions, +children: React.Node, }; type Props = { ...BaseProps, // Redux state +composedMessageMaxWidth: number, +colors: Colors, +contentAndHeaderOpacity: number | Node, +deliveryIconOpacity: number | Node, // withInputState +inputState: ?InputState, +navigateToSidebar: () => mixed, }; class ComposedMessage extends React.PureComponent { render() { assertComposableMessageType(this.props.item.messageInfo.type); const { item, sendFailed, focused, swipeOptions, children, composedMessageMaxWidth, colors, inputState, navigateToSidebar, contentAndHeaderOpacity, deliveryIconOpacity, ...viewProps } = this.props; const { id, creator } = item.messageInfo; const { isViewer } = creator; const alignStyle = isViewer ? styles.rightChatBubble : styles.leftChatBubble; let containerMarginBottom = 5; if (item.endsCluster) { containerMarginBottom += clusterEndHeight; } const containerStyle = [ styles.alignment, { marginBottom: containerMarginBottom }, ]; const messageBoxStyle = { maxWidth: composedMessageMaxWidth }; let deliveryIcon = null; let failedSendInfo = null; if (isViewer) { let deliveryIconName; let deliveryIconColor = `#${item.threadInfo.color}`; if (id !== null && id !== undefined) { deliveryIconName = 'check-circle'; } else if (sendFailed) { deliveryIconName = 'x-circle'; deliveryIconColor = colors.redText; failedSendInfo = ; } else { deliveryIconName = 'circle'; } const animatedStyle: AnimatedStyleObj = { opacity: deliveryIconOpacity }; deliveryIcon = ( ); } const triggerReply = swipeOptions === 'reply' || swipeOptions === 'both' ? this.reply : undefined; const triggerSidebar = swipeOptions === 'sidebar' || swipeOptions === 'both' ? navigateToSidebar : undefined; const messageBox = ( {children} ); let inlineEngagement = null; - if (item.threadCreatedFromMessage || item.reactions.size > 0) { + if ( + item.threadCreatedFromMessage || + Object.keys(item.reactions).length > 0 + ) { const positioning = isViewer ? 'right' : 'left'; const inlineEngagementPositionStyle = positioning === 'left' ? styles.leftInlineEngagement : styles.rightInlineEngagement; inlineEngagement = ( ); } return ( {deliveryIcon} {messageBox} {failedSendInfo} {inlineEngagement} ); } reply = () => { const { inputState, item } = this.props; invariant(inputState, 'inputState should be set in reply'); invariant(item.messageInfo.text, 'text should be set in reply'); inputState.addReply(createMessageReply(item.messageInfo.text)); }; } const styles = StyleSheet.create({ alignment: { marginLeft: composedMessageStyle.marginLeft, marginRight: composedMessageStyle.marginRight, }, content: { alignItems: 'center', flexDirection: 'row-reverse', }, icon: { fontSize: 16, textAlign: 'center', }, iconContainer: { marginLeft: 2, width: 16, }, inlineEngagement: { marginBottom: inlineEngagementStyle.marginBottom, marginTop: inlineEngagementStyle.marginTop, }, leftChatBubble: { justifyContent: 'flex-end', }, leftInlineEngagement: { justifyContent: 'flex-start', position: 'relative', top: inlineEngagementLeftStyle.topOffset, }, messageBox: { marginRight: 5, }, rightChatBubble: { justifyContent: 'flex-start', }, rightInlineEngagement: { alignSelf: 'flex-end', position: 'relative', right: inlineEngagementRightStyle.marginRight, top: inlineEngagementRightStyle.topOffset, }, }); const ConnectedComposedMessage: React.ComponentType = React.memo(function ConnectedComposedMessage(props: BaseProps) { const composedMessageMaxWidth = useComposedMessageMaxWidth(); const colors = useColors(); const inputState = React.useContext(InputStateContext); const navigateToSidebar = useNavigateToSidebar(props.item); const contentAndHeaderOpacity = useContentAndHeaderOpacity(props.item); const deliveryIconOpacity = useDeliveryIconOpacity(props.item); return ( ); }); export default ConnectedComposedMessage; diff --git a/native/chat/inline-engagement.react.js b/native/chat/inline-engagement.react.js index a57c83b81..2c933b85e 100644 --- a/native/chat/inline-engagement.react.js +++ b/native/chat/inline-engagement.react.js @@ -1,247 +1,247 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { Text, View } from 'react-native'; import Animated, { Extrapolate, interpolateNode, } from 'react-native-reanimated'; import useInlineEngagementText from 'lib/hooks/inline-engagement-text.react.js'; -import type { MessageReactionInfo } from 'lib/selectors/chat-selectors.js'; +import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { stringForReactionList } from 'lib/shared/reaction-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { inlineEngagementStyle, inlineEngagementCenterStyle, inlineEngagementRightStyle, composedMessageStyle, } from './chat-constants.js'; import { useNavigateToThread } from './message-list-types.js'; import CommIcon from '../components/comm-icon.react.js'; import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react.js'; import { MessageReactionsModalRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; type Props = { +threadInfo: ?ThreadInfo, - +reactions?: $ReadOnlyMap, + +reactions?: ReactionInfo, +disabled?: boolean, }; function InlineEngagement(props: Props): React.Node { const { disabled = false, reactions, threadInfo } = props; const repliesText = useInlineEngagementText(threadInfo); const navigateToThread = useNavigateToThread(); const { navigate } = useNavigation(); const styles = useStyles(unboundStyles); const unreadStyle = threadInfo?.currentUser.unread ? styles.unread : null; const repliesStyles = React.useMemo( () => [styles.repliesText, unreadStyle], [styles.repliesText, unreadStyle], ); const onPressThread = React.useCallback(() => { if (threadInfo && !disabled) { navigateToThread({ threadInfo }); } }, [disabled, navigateToThread, threadInfo]); const sidebarItem = React.useMemo(() => { if (!threadInfo) { return null; } return ( {repliesText} ); }, [ threadInfo, onPressThread, styles.sidebar, styles.icon, repliesStyles, repliesText, ]); const onPressReactions = React.useCallback(() => { navigate<'MessageReactionsModal'>({ name: MessageReactionsModalRouteName, params: { reactions }, }); }, [navigate, reactions]); const marginLeft = React.useMemo( () => (sidebarItem ? styles.reactionMarginLeft : null), [sidebarItem, styles.reactionMarginLeft], ); const reactionList = React.useMemo(() => { - if (!reactions || reactions.size === 0) { + if (!reactions || Object.keys(reactions).length === 0) { return null; } const reactionText = stringForReactionList(reactions); const reactionItems = {reactionText}; return ( {reactionItems} ); }, [ marginLeft, onPressReactions, reactions, styles.reaction, styles.reactionsContainer, ]); return ( {sidebarItem} {reactionList} ); } const unboundStyles = { container: { flexDirection: 'row', height: inlineEngagementStyle.height, borderRadius: 16, backgroundColor: 'inlineEngagementBackground', alignSelf: 'baseline', alignItems: 'center', padding: 8, }, unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, sidebar: { flexDirection: 'row', alignItems: 'center', }, icon: { color: 'inlineEngagementLabel', marginRight: 4, }, repliesText: { color: 'inlineEngagementLabel', fontSize: 14, lineHeight: 22, }, reaction: { color: 'inlineEngagementLabel', fontSize: 14, lineHeight: 22, }, reactionMarginLeft: { marginLeft: 12, }, reactionsContainer: { display: 'flex', flexDirection: 'row', alignItems: 'center', }, }; type TooltipInlineEngagementProps = { +item: ChatMessageInfoItemWithHeight, +isOpeningSidebar: boolean, +progress: Animated.Node, +windowWidth: number, +positioning: 'left' | 'right' | 'center', +initialCoordinates: { +x: number, +y: number, +width: number, +height: number, }, }; function TooltipInlineEngagement( props: TooltipInlineEngagementProps, ): React.Node { const { item, isOpeningSidebar, progress, windowWidth, initialCoordinates, positioning, } = props; const inlineEngagementStyles = React.useMemo(() => { if (positioning === 'left') { return { position: 'absolute', top: inlineEngagementStyle.marginTop + inlineEngagementRightStyle.topOffset, left: composedMessageStyle.marginLeft, }; } else if (positioning === 'right') { return { position: 'absolute', right: inlineEngagementRightStyle.marginRight + composedMessageStyle.marginRight, top: inlineEngagementStyle.marginTop + inlineEngagementRightStyle.topOffset, }; } else if (positioning === 'center') { return { alignSelf: 'center', top: inlineEngagementCenterStyle.topOffset, }; } }, [positioning]); const inlineEngagementContainer = React.useMemo(() => { const opacity = isOpeningSidebar ? 0 : interpolateNode(progress, { inputRange: [0, 1], outputRange: [1, 0], extrapolate: Extrapolate.CLAMP, }); return { position: 'absolute', width: windowWidth, top: initialCoordinates.height, left: -initialCoordinates.x, opacity, }; }, [ initialCoordinates.height, initialCoordinates.x, isOpeningSidebar, progress, windowWidth, ]); return ( ); } export { InlineEngagement, TooltipInlineEngagement }; diff --git a/native/chat/message-reactions-modal.react.js b/native/chat/message-reactions-modal.react.js index 767225eee..611814b8e 100644 --- a/native/chat/message-reactions-modal.react.js +++ b/native/chat/message-reactions-modal.react.js @@ -1,142 +1,142 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { View, Text, FlatList, TouchableHighlight } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import type { MessageReactionInfo } from 'lib/selectors/chat-selectors.js'; +import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { useMessageReactionsList } from 'lib/shared/reaction-utils.js'; import Modal from '../components/modal.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useColors, useStyles } from '../themes/colors.js'; export type MessageReactionsModalParams = { - +reactions: $ReadOnlyMap, + +reactions: ReactionInfo, }; type Props = { +navigation: RootNavigationProp<'MessageReactionsModal'>, +route: NavigationRoute<'MessageReactionsModal'>, }; function MessageReactionsModal(props: Props): React.Node { const { reactions } = props.route.params; const styles = useStyles(unboundStyles); const colors = useColors(); const navigation = useNavigation(); const modalSafeAreaEdges = React.useMemo(() => ['top'], []); const modalContainerSafeAreaEdges = React.useMemo(() => ['bottom'], []); const close = React.useCallback(() => navigation.goBack(), [navigation]); const reactionsListData = useMessageReactionsList(reactions); const renderItem = React.useCallback( ({ item }) => { return ( {item.username} {item.reaction} ); }, [ styles.reactionsListReactionText, styles.reactionsListRowContainer, styles.reactionsListUsernameText, ], ); const itemSeperator = React.useCallback(() => { return ; }, [styles.reactionsListItemSeperator]); return ( Reactions ); } const unboundStyles = { modalStyle: { // we need to set each margin property explicitly to override marginLeft: 0, marginRight: 0, marginBottom: 0, marginTop: 0, justifyContent: 'flex-end', flex: 0, borderWidth: 0, borderTopLeftRadius: 10, borderTopRightRadius: 10, }, modalContainerStyle: { justifyContent: 'flex-end', }, modalContentContainer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24, marginTop: 8, }, reactionsListContentContainer: { paddingBottom: 16, }, reactionsListTitleText: { color: 'modalForegroundLabel', fontSize: 18, }, reactionsListRowContainer: { flexDirection: 'row', justifyContent: 'space-between', }, reactionsListUsernameText: { color: 'modalForegroundLabel', fontSize: 18, }, reactionsListReactionText: { fontSize: 18, }, reactionsListItemSeperator: { height: 16, }, closeButton: { borderRadius: 4, width: 18, height: 18, alignItems: 'center', }, closeIcon: { color: 'modalBackgroundSecondaryLabel', }, }; export default MessageReactionsModal; diff --git a/native/chat/multimedia-message-utils.js b/native/chat/multimedia-message-utils.js index d77829989..1269d2ce6 100644 --- a/native/chat/multimedia-message-utils.js +++ b/native/chat/multimedia-message-utils.js @@ -1,143 +1,143 @@ // @flow import invariant from 'invariant'; import { messageKey } from 'lib/shared/message-utils.js'; import type { MediaInfo } from 'lib/types/media-types.js'; import type { MultimediaMessageInfo } from 'lib/types/message-types.js'; import { inlineEngagementStyle, clusterEndHeight } from './chat-constants.js'; import { failedSendHeight } from './failed-send.react.js'; import { authorNameHeight } from './message-header.react.js'; import type { ChatMultimediaMessageInfoItem, MultimediaContentSizes, } from '../types/chat-types.js'; const spaceBetweenImages = 4; function getMediaPerRow(mediaCount: number): number { if (mediaCount === 0) { return 0; // ??? } else if (mediaCount === 1) { return 1; } else if (mediaCount === 2) { return 2; } else if (mediaCount === 3) { return 3; } else if (mediaCount === 4) { return 2; } else { return 3; } } function multimediaMessageSendFailed( item: ChatMultimediaMessageInfoItem, ): boolean { const { messageInfo, localMessageInfo, pendingUploads } = item; const { id: serverID } = messageInfo; if (serverID !== null && serverID !== undefined) { return false; } const { isViewer } = messageInfo.creator; if (!isViewer) { return false; } if (localMessageInfo && localMessageInfo.sendFailed) { return true; } for (const media of messageInfo.media) { const pendingUpload = pendingUploads && pendingUploads[media.id]; if (pendingUpload && pendingUpload.failed) { return true; } } return !pendingUploads; } // The results are merged into ChatMultimediaMessageInfoItem function multimediaMessageContentSizes( messageInfo: MultimediaMessageInfo, composedMessageMaxWidth: number, ): MultimediaContentSizes { invariant(messageInfo.media.length > 0, 'should have media'); if (messageInfo.media.length === 1) { const [media] = messageInfo.media; const { height, width } = media.dimensions; let imageHeight = height; if (width > composedMessageMaxWidth) { imageHeight = (height * composedMessageMaxWidth) / width; } if (imageHeight < 50) { imageHeight = 50; } let contentWidth = height ? (width * imageHeight) / height : 0; if (contentWidth > composedMessageMaxWidth) { contentWidth = composedMessageMaxWidth; } return { imageHeight, contentHeight: imageHeight, contentWidth }; } const contentWidth = composedMessageMaxWidth; const mediaPerRow = getMediaPerRow(messageInfo.media.length); const marginSpace = spaceBetweenImages * (mediaPerRow - 1); const imageHeight = (contentWidth - marginSpace) / mediaPerRow; const numRows = Math.ceil(messageInfo.media.length / mediaPerRow); const contentHeight = numRows * imageHeight + (numRows - 1) * spaceBetweenImages; return { imageHeight, contentHeight, contentWidth }; } // Given a ChatMultimediaMessageInfoItem, determines exact height of row function multimediaMessageItemHeight( item: ChatMultimediaMessageInfoItem, ): number { const { messageInfo, contentHeight, startsCluster, endsCluster } = item; const { creator } = messageInfo; const { isViewer } = creator; let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage if (!isViewer && startsCluster) { height += authorNameHeight; } if (endsCluster) { height += clusterEndHeight; } if (multimediaMessageSendFailed(item)) { height += failedSendHeight; } - if (item.threadCreatedFromMessage || item.reactions.size > 0) { + if (item.threadCreatedFromMessage || Object.keys(item.reactions).length > 0) { height += inlineEngagementStyle.height + inlineEngagementStyle.marginTop + inlineEngagementStyle.marginBottom; } return height; } function getMediaKey( item: ChatMultimediaMessageInfoItem, mediaInfo: MediaInfo, ): string { return `multimedia|${messageKey(item.messageInfo)}|${mediaInfo.index}`; } export { multimediaMessageContentSizes, multimediaMessageItemHeight, multimediaMessageSendFailed, getMediaPerRow, spaceBetweenImages, getMediaKey, }; diff --git a/native/chat/reaction-message-utils.js b/native/chat/reaction-message-utils.js index 316ff56a9..59087807d 100644 --- a/native/chat/reaction-message-utils.js +++ b/native/chat/reaction-message-utils.js @@ -1,180 +1,182 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import Alert from 'react-native/Libraries/Alert/Alert.js'; import { sendReactionMessage, sendReactionMessageActionTypes, } from 'lib/actions/message-actions.js'; -import type { MessageReactionInfo } from 'lib/selectors/chat-selectors.js'; +import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { messageTypes } from 'lib/types/message-types.js'; import type { RawReactionMessageInfo } from 'lib/types/messages/reaction.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import { cloneError } from 'lib/utils/errors.js'; import { useSelector } from '../redux/redux-utils.js'; import type { LayoutCoordinates, VerticalBounds, } from '../types/layout-types.js'; import type { ViewStyle } from '../types/styles.js'; function useSendReaction( messageID: ?string, localID: string, threadID: string, - reactions: $ReadOnlyMap, + reactions: ReactionInfo, ): (reaction: string) => mixed { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const callSendReactionMessage = useServerCall(sendReactionMessage); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( reaction => { if (!messageID) { return; } invariant(viewerID, 'viewerID should be set'); - const viewerReacted = reactions.get(reaction)?.viewerReacted; + const viewerReacted = reactions[reaction] + ? reactions[reaction].viewerReacted + : false; const action = viewerReacted ? 'remove_reaction' : 'add_reaction'; const reactionMessagePromise = (async () => { try { const result = await callSendReactionMessage({ threadID, localID, targetMessageID: messageID, reaction, action, }); return { localID, serverID: result.id, threadID, time: result.time, interface: result.interface, }; } catch (e) { Alert.alert( 'Couldn’t send the reaction', 'Please try again later', [{ text: 'OK' }], { cancelable: true, }, ); const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } })(); const startingPayload: RawReactionMessageInfo = { type: messageTypes.REACTION, threadID, localID, creatorID: viewerID, time: Date.now(), targetMessageID: messageID, reaction, action, }; dispatchActionPromise( sendReactionMessageActionTypes, reactionMessagePromise, undefined, startingPayload, ); }, [ messageID, viewerID, reactions, threadID, localID, dispatchActionPromise, callSendReactionMessage, ], ); } type ReactionSelectionPopoverPositionArgs = { +initialCoordinates: LayoutCoordinates, +verticalBounds: VerticalBounds, +margin: ?number, }; function useReactionSelectionPopoverPosition({ initialCoordinates, verticalBounds, margin, }: ReactionSelectionPopoverPositionArgs): ViewStyle { const reactionSelectionPopoverHeight = 56; const calculatedMargin = margin ?? 16; const windowWidth = useSelector(state => state.dimensions.width); const reactionSelectionPopoverLocation: 'above' | 'below' = (() => { const { y, height } = initialCoordinates; const contentTop = y; const contentBottom = y + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const fullHeight = reactionSelectionPopoverHeight + calculatedMargin; if ( contentBottom + fullHeight > boundsBottom && contentTop - fullHeight > boundsTop ) { return 'above'; } return 'below'; })(); return React.useMemo(() => { const { x, width, height } = initialCoordinates; const style = {}; style.position = 'absolute'; const extraLeftSpace = x; const extraRightSpace = windowWidth - width - x; if (extraLeftSpace < extraRightSpace) { style.left = 0; } else { style.right = 0; } if (reactionSelectionPopoverLocation === 'above') { style.bottom = height + calculatedMargin / 2; } else { style.top = height + calculatedMargin / 2; } return style; }, [ calculatedMargin, initialCoordinates, reactionSelectionPopoverLocation, windowWidth, ]); } export { useSendReaction, useReactionSelectionPopoverPosition }; diff --git a/native/chat/robotext-message.react.js b/native/chat/robotext-message.react.js index 956edd5c1..db7deeb3f 100644 --- a/native/chat/robotext-message.react.js +++ b/native/chat/robotext-message.react.js @@ -1,214 +1,214 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { messageKey } from 'lib/shared/message-utils.js'; import { useCanCreateSidebarFromMessage } from 'lib/shared/thread-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 { 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'>, +route: NavigationRoute<'MessageList'>, +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); let inlineEngagement = null; - if (item.threadCreatedFromMessage || item.reactions.size > 0) { + if (item.threadCreatedFromMessage || Object.keys(item.reactions).length > 0) { inlineEngagement = ( ); } const chatContext = React.useContext(ChatContext); const keyboardState = React.useContext(KeyboardContext); const key = messageKey(item.messageInfo); const onPress = React.useCallback(() => { const didDismiss = keyboardState && keyboardState.dismissKeyboardIfShowing(); if (!didDismiss) { toggleFocus(key); } }, [keyboardState, toggleFocus, key]); const overlayContext = React.useContext(OverlayContext); const viewRef = React.useRef>(); const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( item.threadInfo, item.messageInfo, ); const visibleEntryIDs = React.useMemo(() => { const result = []; if (item.threadCreatedFromMessage || canCreateSidebarFromMessage) { result.push('sidebar'); } return result; }, [item.threadCreatedFromMessage, canCreateSidebarFromMessage]); const openRobotextTooltipModal = React.useCallback( (x, y, width, height, pageX, pageY) => { 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 { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; 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(messageKey(item.messageInfo)); } invariant(overlayContext, 'RobotextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); viewRef.current?.measure(openRobotextTooltipModal); }, [ focused, item, keyboardState, overlayContext, toggleFocus, verticalBounds, viewRef, visibleEntryIDs, openRobotextTooltipModal, ]); const onLayout = React.useCallback(() => {}, []); const contentAndHeaderOpacity = useContentAndHeaderOpacity(item); return ( {timestamp} {inlineEngagement} ); } const unboundStyles = { sidebar: { marginTop: inlineEngagementCenterStyle.topOffset, marginBottom: -inlineEngagementCenterStyle.topOffset, alignSelf: 'center', }, }; export { RobotextMessage }; diff --git a/native/chat/utils.js b/native/chat/utils.js index ab4950614..bbff12acb 100644 --- a/native/chat/utils.js +++ b/native/chat/utils.js @@ -1,409 +1,409 @@ // @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 { useMessageListData } from 'lib/selectors/chat-selectors.js'; import type { ChatMessageItem } from 'lib/selectors/chat-selectors.js'; import { messageKey } from 'lib/shared/message-utils.js'; import { colorIsDark, viewerIsMember } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { clusterEndHeight, inlineEngagementStyle } from './chat-constants.js'; import { ChatContext, useHeightMeasurer } from './chat-context.js'; import { failedSendHeight } from './failed-send.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, ChatRobotextMessageInfoItemWithHeight, ChatTextMessageInfoItemWithHeight, } from '../types/chat-types.js'; import type { LayoutCoordinates, VerticalBounds, } from '../types/layout-types.js'; import type { AnimatedViewStyle } from '../types/styles.js'; /* eslint-disable import/no-named-as-default-member */ const { Node, Extrapolate, interpolateNode, interpolateColors, block, call, eq, cond, sub, } = Animated; /* eslint-enable import/no-named-as-default-member */ 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; } - if (item.threadCreatedFromMessage || item.reactions.size > 0) { + if (item.threadCreatedFromMessage || Object.keys(item.reactions).length > 0) { height += inlineEngagementStyle.height + inlineEngagementStyle.marginTop + inlineEngagementStyle.marginBottom; } return height; } function robotextMessageItemHeight( item: ChatRobotextMessageInfoItemWithHeight, ): number { - if (item.threadCreatedFromMessage || item.reactions.size > 0) { + if (item.threadCreatedFromMessage || Object.keys(item.reactions).length > 0) { return item.contentHeight + inlineEngagementStyle.height; } return item.contentHeight; } 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 += robotextMessageItemHeight(item); } 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 = useMessageListData({ 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 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 sidebarThreadInfo = React.useMemo( () => getUnresolvedSidebarThreadInfo({ sourceMessage, loggedInUserInfo }), [sourceMessage, loggedInUserInfo], ); 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|${messageKey(item.messageInfo)}`; } 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 chatMessageItemKey( item: ChatMessageItemWithHeight | ChatMessageItem, ): string { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } export { chatMessageItemKey, chatMessageItemHeight, useAnimatedMessageTooltipButton, messageItemHeight, getMessageTooltipKey, isMessageTooltipKey, useContentAndHeaderOpacity, useDeliveryIconOpacity, }; diff --git a/native/types/chat-types.js b/native/types/chat-types.js index 5a493171a..9ee7ec414 100644 --- a/native/types/chat-types.js +++ b/native/types/chat-types.js @@ -1,70 +1,70 @@ // @flow -import type { MessageReactionInfo } from 'lib/selectors/chat-selectors.js'; +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/thread-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, +threadInfo: ThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, +robotext: EntityText, +threadCreatedFromMessage: ?ThreadInfo, +contentHeight: number, - +reactions: $ReadOnlyMap, + +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: $ReadOnlyMap, + +reactions: ReactionInfo, }; 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: $ReadOnlyMap, + +reactions: ReactionInfo, }; export type ChatMessageInfoItemWithHeight = | ChatRobotextMessageInfoItemWithHeight | ChatTextMessageInfoItemWithHeight | ChatMultimediaMessageInfoItem; export type ChatMessageItemWithHeight = | { itemType: 'loader' } | ChatMessageInfoItemWithHeight; diff --git a/web/chat/composed-message.react.js b/web/chat/composed-message.react.js index 5d4899376..3c35bbf60 100644 --- a/web/chat/composed-message.react.js +++ b/web/chat/composed-message.react.js @@ -1,198 +1,198 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { Circle as CircleIcon, CheckCircle as CheckCircleIcon, XCircle as XCircleIcon, } from 'react-feather'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { assertComposableMessageType } from 'lib/types/message-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import css from './chat-message-list.css'; import FailedSend from './failed-send.react.js'; import InlineEngagement from './inline-engagement.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import { tooltipPositions, useMessageTooltip } from '../utils/tooltip-utils.js'; const availableTooltipPositionsForViewerMessage = [ tooltipPositions.LEFT, tooltipPositions.LEFT_BOTTOM, tooltipPositions.LEFT_TOP, tooltipPositions.RIGHT, tooltipPositions.RIGHT_BOTTOM, tooltipPositions.RIGHT_TOP, tooltipPositions.BOTTOM, tooltipPositions.TOP, ]; const availableTooltipPositionsForNonViewerMessage = [ tooltipPositions.RIGHT, tooltipPositions.RIGHT_BOTTOM, tooltipPositions.RIGHT_TOP, tooltipPositions.LEFT, tooltipPositions.LEFT_BOTTOM, tooltipPositions.LEFT_TOP, tooltipPositions.BOTTOM, tooltipPositions.TOP, ]; type BaseProps = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +sendFailed: boolean, +children: React.Node, +fixedWidth?: boolean, +borderRadius: number, }; type BaseConfig = React.Config; type Props = { ...BaseProps, // withInputState +inputState: ?InputState, +onMouseLeave: ?() => mixed, +onMouseEnter: (event: SyntheticEvent) => mixed, +containsInlineEngagement: boolean, +stringForUser: ?string, }; class ComposedMessage extends React.PureComponent { static defaultProps: { +borderRadius: number } = { borderRadius: 8, }; render(): React.Node { assertComposableMessageType(this.props.item.messageInfo.type); const { borderRadius, item, threadInfo } = this.props; const { id, creator } = item.messageInfo; const threadColor = threadInfo.color; const { isViewer } = creator; const contentClassName = classNames({ [css.content]: true, [css.viewerContent]: isViewer, [css.nonViewerContent]: !isViewer, }); const messageBoxContainerClassName = classNames({ [css.messageBoxContainer]: true, [css.fixedWidthMessageBoxContainer]: this.props.fixedWidth, }); const messageBoxClassName = classNames({ [css.messageBox]: true, [css.fixedWidthMessageBox]: this.props.fixedWidth, }); const messageBoxStyle = { borderTopRightRadius: isViewer && !item.startsCluster ? 0 : borderRadius, borderBottomRightRadius: isViewer && !item.endsCluster ? 0 : borderRadius, borderTopLeftRadius: !isViewer && !item.startsCluster ? 0 : borderRadius, borderBottomLeftRadius: !isViewer && !item.endsCluster ? 0 : borderRadius, }; let authorName = null; const { stringForUser } = this.props; if (stringForUser) { authorName = {stringForUser}; } let deliveryIcon = null; let failedSendInfo = null; if (isViewer) { let deliveryIconSpan; let deliveryIconColor = threadColor; if (id !== null && id !== undefined) { deliveryIconSpan = ; } else if (this.props.sendFailed) { deliveryIconSpan = ; deliveryIconColor = 'FF0000'; failedSendInfo = ; } else { deliveryIconSpan = ; } deliveryIcon = (
{deliveryIconSpan}
); } let inlineEngagement = null; if ( (this.props.containsInlineEngagement && item.threadCreatedFromMessage) || - item.reactions.size > 0 + Object.keys(item.reactions).length > 0 ) { const positioning = isViewer ? 'right' : 'left'; inlineEngagement = (
); } return ( {authorName}
{this.props.children}
{deliveryIcon}
{failedSendInfo} {inlineEngagement}
); } } type ConnectedConfig = React.Config< BaseProps, typeof ComposedMessage.defaultProps, >; const ConnectedComposedMessage: React.ComponentType = React.memo(function ConnectedComposedMessage(props) { const { item, threadInfo } = props; const inputState = React.useContext(InputStateContext); const { creator } = props.item.messageInfo; const { isViewer } = creator; const availablePositions = isViewer ? availableTooltipPositionsForViewerMessage : availableTooltipPositionsForNonViewerMessage; const containsInlineEngagement = !!item.threadCreatedFromMessage; const { onMouseLeave, onMouseEnter } = useMessageTooltip({ item, threadInfo, availablePositions, }); const shouldShowUsername = !isViewer && item.startsCluster; const stringForUser = useStringForUser(shouldShowUsername ? creator : null); return ( ); }); export default ConnectedComposedMessage; diff --git a/web/chat/inline-engagement.react.js b/web/chat/inline-engagement.react.js index bf59d853b..14a1d3a33 100644 --- a/web/chat/inline-engagement.react.js +++ b/web/chat/inline-engagement.react.js @@ -1,98 +1,98 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import useInlineEngagementText from 'lib/hooks/inline-engagement-text.react.js'; -import type { MessageReactionInfo } from 'lib/selectors/chat-selectors.js'; +import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { stringForReactionList } from 'lib/shared/reaction-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import css from './inline-engagement.css'; import CommIcon from '../CommIcon.react.js'; import MessageReactionsModal from '../modals/chat/message-reactions-modal.react.js'; import { useOnClickThread } from '../selectors/thread-selectors.js'; type Props = { +threadInfo: ?ThreadInfo, - +reactions?: $ReadOnlyMap, + +reactions?: ReactionInfo, +positioning: 'left' | 'center' | 'right', }; function InlineEngagement(props: Props): React.Node { const { threadInfo, reactions, positioning } = props; const { pushModal, popModal } = useModalContext(); const repliesText = useInlineEngagementText(threadInfo); const containerClasses = classNames([ css.inlineEngagementContainer, { [css.leftContainer]: positioning === 'left', [css.centerContainer]: positioning === 'center', [css.rightContainer]: positioning === 'right', }, ]); - const reactionsExist = reactions && reactions.size > 0; + const reactionsExist = reactions && Object.keys(reactions).length > 0; const threadsContainerClasses = classNames({ [css.threadsContainer]: threadInfo && !reactionsExist, [css.threadsSplitContainer]: threadInfo && reactionsExist, }); const reactionsContainerClasses = classNames({ [css.reactionsContainer]: reactionsExist && !threadInfo, [css.reactionsSplitContainer]: reactionsExist && threadInfo, }); const onClickThread = useOnClickThread(threadInfo); const sidebarItem = React.useMemo(() => { if (!threadInfo || !repliesText) { return null; } return ( {repliesText} ); }, [threadInfo, repliesText, onClickThread, threadsContainerClasses]); const onClickReactions = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); if (!reactions) { return; } pushModal( , ); }, [popModal, pushModal, reactions], ); const reactionsList = React.useMemo(() => { - if (!reactions || reactions.size === 0) { + if (!reactions || Object.keys(reactions).length === 0) { return null; } const reactionText = stringForReactionList(reactions); return ( {reactionText} ); }, [reactions, onClickReactions, reactionsContainerClasses]); return (
{sidebarItem} {reactionsList}
); } export default InlineEngagement; diff --git a/web/chat/message-tooltip.react.js b/web/chat/message-tooltip.react.js index 939f93c25..fd57bb78c 100644 --- a/web/chat/message-tooltip.react.js +++ b/web/chat/message-tooltip.react.js @@ -1,169 +1,172 @@ // @flow import data from '@emoji-mart/data'; import Picker from '@emoji-mart/react'; import classNames from 'classnames'; import * as React from 'react'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { localIDPrefix } from 'lib/shared/message-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { tooltipButtonStyle, tooltipLabelStyle, tooltipStyle, } from './chat-constants.js'; import css from './message-tooltip.css'; import { useSendReaction } from './reaction-message-utils.js'; import { useTooltipContext } from './tooltip-provider.js'; import { useSelector } from '../redux/redux-utils.js'; import { type MessageTooltipAction } from '../utils/tooltip-utils.js'; type MessageTooltipProps = { +actions: $ReadOnlyArray, +messageTimestamp: string, +alignment?: 'left' | 'center' | 'right', +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, }; function MessageTooltip(props: MessageTooltipProps): React.Node { const { actions, messageTimestamp, alignment = 'left', item, threadInfo, } = props; const { messageInfo, reactions } = item; const [activeTooltipLabel, setActiveTooltipLabel] = React.useState(); const { renderEmojiKeyboard } = useTooltipContext(); const messageActionButtonsContainerClassName = classNames( css.messageActionContainer, css.messageActionButtons, ); const messageTooltipButtonStyle = React.useMemo(() => tooltipButtonStyle, []); const tooltipButtons = React.useMemo(() => { if (!actions || actions.length === 0) { return null; } const buttons = actions.map(({ label, onClick, actionButtonContent }) => { const onMouseEnter = () => { setActiveTooltipLabel(label); }; const onMouseLeave = () => setActiveTooltipLabel(oldLabel => label === oldLabel ? null : oldLabel, ); return (
{actionButtonContent}
); }); return (
{buttons}
); }, [ actions, messageActionButtonsContainerClassName, messageTooltipButtonStyle, ]); const messageTooltipLabelStyle = React.useMemo(() => tooltipLabelStyle, []); const messageTooltipTopLabelStyle = React.useMemo( () => ({ height: `${tooltipLabelStyle.height + 2 * tooltipLabelStyle.padding}px`, }), [], ); const tooltipLabel = React.useMemo(() => { if (!activeTooltipLabel) { return null; } return (
{activeTooltipLabel}
); }, [activeTooltipLabel, messageTooltipLabelStyle]); const tooltipTimestamp = React.useMemo(() => { if (!messageTimestamp) { return null; } return (
{messageTimestamp}
); }, [messageTimestamp, messageTooltipLabelStyle]); const nextLocalID = useSelector(state => state.nextLocalID); const localID = `${localIDPrefix}${nextLocalID}`; const sendReaction = useSendReaction(messageInfo.id, localID, threadInfo.id); const onEmojiSelect = React.useCallback( emoji => { const reactionInput = emoji.native; - const viewerReacted = !!reactions.get(reactionInput)?.viewerReacted; + + const viewerReacted = reactions[reactionInput] + ? reactions[reactionInput].viewerReacted + : false; const action = viewerReacted ? 'remove_reaction' : 'add_reaction'; sendReaction(reactionInput, action); }, [sendReaction, reactions], ); const emojiKeyboard = React.useMemo(() => { if (!renderEmojiKeyboard) { return null; } return ; }, [onEmojiSelect, renderEmojiKeyboard]); const messageTooltipContainerStyle = React.useMemo(() => tooltipStyle, []); const containerClassName = classNames({ [css.container]: true, [css.containerLeftAlign]: alignment === 'left', [css.containerCenterAlign]: alignment === 'center', }); const messageTooltipContainerClassNames = classNames({ [css.messageTooltipContainer]: true, [css.leftTooltipAlign]: alignment === 'left', [css.centerTooltipAlign]: alignment === 'center', [css.rightTooltipAlign]: alignment === 'right', }); return (
{emojiKeyboard}
{tooltipLabel}
{tooltipButtons} {tooltipTimestamp}
); } export default MessageTooltip; diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js index 11432fca6..43f2a29b0 100644 --- a/web/chat/robotext-message.react.js +++ b/web/chat/robotext-message.react.js @@ -1,143 +1,143 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { type RobotextChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { entityTextToReact, useENSNamesForEntityText, } from 'lib/utils/entity-text.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 { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import { tooltipPositions, useMessageTooltip } from '../utils/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 } = props; const { threadCreatedFromMessage, reactions } = item; - if (threadCreatedFromMessage || reactions.size > 0) { + if (threadCreatedFromMessage || Object.keys(reactions).length > 0) { inlineEngagement = (
); } const { messageInfo, robotext } = item; const { threadID } = messageInfo; const robotextWithENSNames = useENSNamesForEntityText(robotext); invariant( robotextWithENSNames, 'useENSNamesForEntityText only returns falsey when passed falsey', ); const textParts = React.useMemo(() => { return entityTextToReact(robotextWithENSNames, threadID, { // eslint-disable-next-line react/display-name renderText: ({ text }) => ( {text} ), // eslint-disable-next-line react/display-name renderThread: ({ id, name }) => , // eslint-disable-next-line react/display-name renderColor: ({ hex }) => , }); }, [robotextWithENSNames, threadID]); const { threadInfo } = props; 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() { 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 ( ); }, ); function ColorEntity(props: { color: string }) { const colorStyle = { color: props.color }; return {props.color}; } const MemoizedRobotextMessage: React.ComponentType = React.memo(RobotextMessage); export default MemoizedRobotextMessage; diff --git a/web/modals/chat/message-reactions-modal.react.js b/web/modals/chat/message-reactions-modal.react.js index 60d3f4bc3..088c99eac 100644 --- a/web/modals/chat/message-reactions-modal.react.js +++ b/web/modals/chat/message-reactions-modal.react.js @@ -1,39 +1,39 @@ // @flow import * as React from 'react'; -import type { MessageReactionInfo } from 'lib/selectors/chat-selectors.js'; +import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { useMessageReactionsList } from 'lib/shared/reaction-utils.js'; import css from './message-reactions-modal.css'; import Modal from '../modal.react.js'; type Props = { +onClose: () => void, - +reactions: $ReadOnlyMap, + +reactions: ReactionInfo, }; function MessageReactionsModal(props: Props): React.Node { const { onClose, reactions } = props; const messageReactionsList = useMessageReactionsList(reactions); const reactionsList = React.useMemo( () => messageReactionsList.map(messageReactionUser => (
{messageReactionUser.username}
{messageReactionUser.reaction}
)), [messageReactionsList], ); return (
{reactionsList}
); } export default MessageReactionsModal;