diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 34aed8336..9bd5bee7a 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1071 +1,1108 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find'; import * as React from 'react'; import { type ParserRules } from 'simple-markdown'; import tinycolor from 'tinycolor2'; import { fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from '../actions/message-actions'; import { newThread, newThreadActionTypes } from '../actions/thread-actions'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions'; -import type { ChatThreadItem } from '../selectors/chat-selectors'; +import type { + ChatThreadItem, + ChatMessageInfoItem, +} from '../selectors/chat-selectors'; import { threadInfoSelector, threadInfoFromSourceMessageIDSelector, } from '../selectors/thread-selectors'; import type { MultimediaMessageInfo, RobotextMessageInfo, } from '../types/message-types'; import type { TextMessageInfo } from '../types/messages/text'; -import { userRelationshipStatus } from '../types/relationship-types'; +import { + type UserRelationshipStatus, + userRelationshipStatus, +} from '../types/relationship-types'; import { type RawThreadInfo, type ThreadInfo, type ThreadPermission, type MemberInfo, type ServerThreadInfo, type RelativeMemberInfo, type ThreadCurrentUserInfo, type RoleInfo, type ServerMemberInfo, type ThreadPermissionsInfo, type ThreadType, threadTypes, threadPermissions, } from '../types/thread-types'; import type { NewThreadRequest, NewThreadResult, OptimisticThreadInfo, } from '../types/thread-types'; import { type UpdateInfo, updateTypes } from '../types/update-types'; import type { GlobalAccountUserInfo, UserInfos, UserInfo, AccountUserInfo, } from '../types/user-types'; import { useDispatchActionPromise, useServerCall } from '../utils/action-utils'; import type { DispatchActionPromise } from '../utils/action-utils'; import { useSelector } from '../utils/redux-utils'; import { pluralize, trimText } from '../utils/text-utils'; import { getMessageTitle } from './message-utils'; import { relationshipBlockedInEitherDirection } from './relationship-utils'; import threadWatcher from './thread-watcher'; function colorIsDark(color: string) { return tinycolor(`#${color}`).isDark(); } // Randomly distributed in RGB-space const hexNumerals = '0123456789abcdef'; function generateRandomColor() { let color = ''; for (let i = 0; i < 6; i++) { color += hexNumerals[Math.floor(Math.random() * 16)]; } return color; } function generatePendingThreadColor( userIDs: $ReadOnlyArray, viewerID: string, ) { const ids = [...userIDs, viewerID].sort().join('#'); let hash = 0; for (let i = 0; i < ids.length; i++) { hash = 1009 * hash + ids.charCodeAt(i) * 83; hash %= 1000000007; } const hashString = hash.toString(16); return hashString.substring(hashString.length - 6).padStart(6, '8'); } function threadHasPermission( threadInfo: ?(ThreadInfo | RawThreadInfo), permission: ThreadPermission, ): boolean { invariant( !permissionsDisabledByBlock.has(permission) || threadInfo?.uiName, `${permission} can be disabled by a block, but threadHasPermission can't ` + 'check for a block on RawThreadInfo. Please pass in ThreadInfo instead!', ); if (!threadInfo || !threadInfo.currentUser.permissions[permission]) { return false; } return threadInfo.currentUser.permissions[permission].value; } function viewerIsMember(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function threadIsTopLevel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInChatList(threadInfo) && threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function threadInBackgroundChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?(ThreadInfo | RawThreadInfo), userID: string, ): boolean { if (!threadInfo) { return false; } return threadInfo.members.some( (member) => member.id === userID && member.role !== null && member.role !== undefined, ); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter( (memberInfo) => memberInfo.role !== null && memberInfo.role !== undefined, ) .map((memberInfo) => memberInfo.id); } function threadIsGroupChat(threadInfo: ThreadInfo | RawThreadInfo) { return ( threadInfo.members.filter( (member) => member.role || member.permissions[threadPermissions.VOICED]?.value, ).length > 2 ); } function threadOrParentThreadIsGroupChat( threadInfo: RawThreadInfo | ThreadInfo, ) { return threadInfo.members.length > 2; } function threadIsPending(threadID: ?string) { return threadID?.startsWith('pending'); } function threadIsPersonalAndPending(threadInfo: ?(ThreadInfo | RawThreadInfo)) { return ( threadInfo?.type === threadTypes.PERSONAL && threadIsPending(threadInfo?.id) ); } function getPendingThreadOtherUsers(threadInfo: ThreadInfo | RawThreadInfo) { invariant(threadIsPending(threadInfo.id), 'Thread should be pending'); const otherUserIDs = threadInfo.id.split('/')[1]; invariant( otherUserIDs || threadInfo.type === threadTypes.SIDEBAR, 'Pending threads should contain other members id in its id', ); if (!otherUserIDs) { return []; } return otherUserIDs.split('+'); } function getSingleOtherUser( threadInfo: ThreadInfo | RawThreadInfo, viewerID: ?string, ) { if (!viewerID) { return undefined; } const otherMemberIDs = threadInfo.members .map((member) => member.id) .filter((id) => id !== viewerID); if (otherMemberIDs.length !== 1) { return undefined; } return otherMemberIDs[0]; } function getPendingThreadKey( memberIDs: $ReadOnlyArray, sourceMessageID: ?string, ) { const membersBasedID = [...memberIDs].sort().join('+'); return sourceMessageID ? `${membersBasedID}/${sourceMessageID}` : membersBasedID; } type CreatePendingThreadArgs = {| +viewerID: string, +threadType: ThreadType, +members?: $ReadOnlyArray, +parentThreadID?: ?string, +threadColor?: ?string, +name?: ?string, +sourceMessageID?: string, |}; function createPendingThread({ viewerID, threadType, members, parentThreadID, threadColor, name, sourceMessageID, }: CreatePendingThreadArgs) { const now = Date.now(); members = members ?? []; const memberIDs = members.map((member) => member.id); const threadID = `pending/${getPendingThreadKey(memberIDs, sourceMessageID)}`; const permissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role = { id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }; const rawThreadInfo = { id: threadID, type: threadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs, viewerID), creationTime: now, parentThreadID: parentThreadID ?? null, members: [ { id: viewerID, role: role.id, permissions: membershipPermissions, isSender: false, }, ...members.map((member) => ({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, })), ], roles: { [role.id]: role, }, currentUser: { role: role.id, permissions: membershipPermissions, subscription: { pushNotifs: false, home: false, }, unread: false, }, repliesCount: 0, sourceMessageID, }; const userInfos = {}; members.forEach((member) => (userInfos[member.id] = member)); return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } function createPendingThreadItem( viewerID: string, user: GlobalAccountUserInfo, ): ChatThreadItem { const threadInfo = createPendingThread({ viewerID, threadType: threadTypes.PERSONAL, members: [user], }); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo: { id: user.id, username: user.username, }, }; } function createPendingSidebar( messageInfo: TextMessageInfo | MultimediaMessageInfo | RobotextMessageInfo, threadInfo: ThreadInfo, viewerID: string, markdownRules: ParserRules, ) { const { id, username } = messageInfo.creator; const { id: parentThreadID, color } = threadInfo; const messageTitle = getMessageTitle( messageInfo, threadInfo, markdownRules, 'global_viewer', ); const threadName = trimText(messageTitle, 30); invariant(username, 'username should be set in createPendingSidebar'); const initialMemberUserInfo: GlobalAccountUserInfo = { id, username }; const creatorIsMember = userIsMember(threadInfo, id); return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members: creatorIsMember ? [initialMemberUserInfo] : [], parentThreadID, threadColor: color, name: threadName, sourceMessageID: messageInfo.id, }); } function pendingThreadType(numberOfOtherMembers: number) { return numberOfOtherMembers === 1 ? threadTypes.PERSONAL : threadTypes.CHAT_SECRET; } async function createRealThreadFromPendingThread( threadInfo: ThreadInfo, dispatchActionPromise: DispatchActionPromise, createNewThread: (request: NewThreadRequest) => Promise, sourceMessageID: ?string, handleError?: () => mixed, ): Promise { if (!threadIsPending(threadInfo.id)) { return threadInfo.id; } const otherMemberIDs = getPendingThreadOtherUsers(threadInfo); try { let resultPromise; if (threadInfo.type !== threadTypes.SIDEBAR) { invariant( otherMemberIDs.length > 0, 'otherMemberIDs should not be empty for threads', ); resultPromise = createNewThread({ type: pendingThreadType(otherMemberIDs.length), initialMemberIDs: otherMemberIDs, color: threadInfo.color, }); } else { invariant( sourceMessageID, 'sourceMessageID should be set when creating a sidebar', ); resultPromise = createNewThread({ type: threadTypes.SIDEBAR, initialMemberIDs: otherMemberIDs, color: threadInfo.color, sourceMessageID, parentThreadID: threadInfo.parentThreadID, name: threadInfo.name, }); } dispatchActionPromise(newThreadActionTypes, resultPromise); const { newThreadID } = await resultPromise; return newThreadID; } catch (e) { if (handleError) { handleError(); return undefined; } else { throw e; } } } function useRealThreadCreator( thread: ?OptimisticThreadInfo, handleError?: () => mixed, ) { const creationResultRef = React.useRef(); const threadInfo = thread?.threadInfo; const threadID = threadInfo?.id; const creationResult = creationResultRef.current; const serverThreadID = React.useMemo(() => { if (threadID && !threadIsPending(threadID)) { return threadID; } else if (creationResult && creationResult.pendingThreadID === threadID) { return creationResult.serverThreadID; } return null; }, [threadID, creationResult]); const sourceMessageID = thread?.sourceMessageID; const dispatchActionPromise = useDispatchActionPromise(); const callNewThread = useServerCall(newThread); return React.useCallback(async () => { if (serverThreadID) { return serverThreadID; } else if (!threadInfo) { return null; } const newThreadID = await createRealThreadFromPendingThread( threadInfo, dispatchActionPromise, callNewThread, sourceMessageID, handleError, ); creationResultRef.current = { pendingThreadID: threadInfo.id, serverThreadID: newThreadID, }; return newThreadID; }, [ callNewThread, dispatchActionPromise, handleError, serverThreadID, sourceMessageID, threadInfo, ]); } type RawThreadInfoOptions = {| +includeVisibilityRules?: ?boolean, +filterMemberList?: ?boolean, |}; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?RawThreadInfo { const includeVisibilityRules = options?.includeVisibilityRules; const filterMemberList = options?.filterMemberList; const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( filterMemberList && serverMember.id !== viewerID && !serverMember.role && !memberHasAdminPowers(serverMember) ) { continue; } members.push({ id: serverMember.id, role: serverMember.role, permissions: serverMember.permissions, isSender: serverMember.isSender, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: serverMember.permissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = getAllThreadPermissions(null, serverThreadInfo.id); currentUser = { role: null, permissions: currentUserPermissions, subscription: { home: false, pushNotifs: false, }, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } const rawThreadInfo: RawThreadInfo = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: serverThreadInfo.roles, currentUser, repliesCount: serverThreadInfo.repliesCount, }; const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo.sourceMessageID = sourceMessageID; } if (!includeVisibilityRules) { return rawThreadInfo; } return ({ ...rawThreadInfo, visibilityRules: rawThreadInfo.type, }: any); } function robotextName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { const threadUsernames: string[] = threadInfo.members .filter( (threadMember) => threadMember.id !== viewerID && (threadMember.role || memberHasAdminPowers(threadMember)), ) .map( (threadMember) => userInfos[threadMember.id] && userInfos[threadMember.id].username, ) .filter(Boolean); if (threadUsernames.length === 0) { return 'just you'; } return pluralize(threadUsernames); } function threadUIName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { if (threadInfo.name) { return threadInfo.name; } return robotextName(threadInfo, viewerID, userInfos); } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { const threadInfo: ThreadInfo = { id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: threadUIName(rawThreadInfo, viewerID, userInfos), description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, members: rawThreadInfo.members, roles: rawThreadInfo.roles, currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos), repliesCount: rawThreadInfo.repliesCount, }; const { sourceMessageID } = rawThreadInfo; if (sourceMessageID) { threadInfo.sourceMessageID = sourceMessageID; } return threadInfo; } function getCurrentUser( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } return { ...threadInfo.currentUser, permissions: { ...threadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function threadIsWithBlockedUserOnly( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo) ) { return false; } const otherUserID = getSingleOtherUser(threadInfo, viewerID); if (!otherUserID) { return false; } const otherUserRelationshipStatus = userInfos[otherUserID]?.relationshipStatus; if (checkOnlyViewerBlock) { return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ); } return ( !!otherUserRelationshipStatus && relationshipBlockedInEitherDirection(otherUserRelationshipStatus) ); } function threadFrozenDueToBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true); } function rawThreadInfoFromThreadInfo(threadInfo: ThreadInfo): RawThreadInfo { const rawThreadInfo: RawThreadInfo = { id: threadInfo.id, type: threadInfo.type, name: threadInfo.name, description: threadInfo.description, color: threadInfo.color, creationTime: threadInfo.creationTime, parentThreadID: threadInfo.parentThreadID, members: threadInfo.members, roles: threadInfo.roles, currentUser: threadInfo.currentUser, repliesCount: threadInfo.repliesCount, }; const { sourceMessageID } = threadInfo; if (sourceMessageID) { rawThreadInfo.sourceMessageID = sourceMessageID; } return rawThreadInfo; } const threadTypeDescriptions = { [threadTypes.CHAT_NESTED_OPEN]: 'Anybody in the parent thread can see an open child thread.', [threadTypes.CHAT_SECRET]: 'Only visible to its members and admins of ancestor threads.', }; function usersInThreadInfo(threadInfo: RawThreadInfo | ThreadInfo): string[] { const userIDs = new Set(); for (let member of threadInfo.members) { userIDs.add(member.id); } return [...userIDs]; } function memberIsAdmin( memberInfo: RelativeMemberInfo | MemberInfo, threadInfo: ThreadInfo | RawThreadInfo, ) { return memberInfo.role && roleIsAdminRole(threadInfo.roles[memberInfo.role]); } // Since we don't have access to all of the ancestor ThreadInfos, we approximate // "parent admin" as anybody with CHANGE_ROLE permissions. function memberHasAdminPowers( memberInfo: RelativeMemberInfo | MemberInfo | ServerMemberInfo, ): boolean { return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsAdminRole(roleInfo: ?RoleInfo) { return roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'; } function threadHasAdminRole( threadInfo: ?(RawThreadInfo | ThreadInfo | ServerThreadInfo), ) { if (!threadInfo) { return false; } return _find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: RawThreadInfo | ThreadInfo, ) { return ( threadInfo.members.filter((member) => memberHasAdminPowers(member)).length > 0 ); } function identifyInvalidatedThreads( updateInfos: $ReadOnlyArray, ): Set { const invalidated = new Set(); for (const updateInfo of updateInfos) { if (updateInfo.type === updateTypes.DELETE_THREAD) { invalidated.add(updateInfo.threadID); } } return invalidated; } const permissionsDisabledByBlockArray = [ threadPermissions.VOICED, threadPermissions.EDIT_ENTRIES, threadPermissions.EDIT_THREAD, threadPermissions.CREATE_SUBTHREADS, threadPermissions.CREATE_SIDEBARS, threadPermissions.JOIN_THREAD, threadPermissions.EDIT_PERMISSIONS, threadPermissions.ADD_MEMBERS, threadPermissions.REMOVE_MEMBERS, ]; const permissionsDisabledByBlock: Set = new Set( permissionsDisabledByBlockArray, ); const disabledPermissions: ThreadPermissionsInfo = permissionsDisabledByBlockArray.reduce( (permissions: ThreadPermissionsInfo, permission: string) => ({ ...permissions, [permission]: { value: false, source: null }, }), {}, ); // Consider updating itemHeight in native/chat/chat-thread-list.react.js // if you change this const emptyItemText = `Background threads are just like normal threads, except they don't ` + `contribute to your unread count.\n\n` + `To move a thread over here, switch the “Background” option in its settings.`; const threadSearchText = ( threadInfo: RawThreadInfo | ThreadInfo, userInfos: UserInfos, ): string => { const searchTextArray = []; if (threadInfo.name) { searchTextArray.push(threadInfo.name); } if (threadInfo.description) { searchTextArray.push(threadInfo.description); } for (let member of threadInfo.members) { const isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } const userInfo = userInfos[member.id]; if (userInfo && userInfo.username) { searchTextArray.push(userInfo.username); } } return searchTextArray.join(' '); }; function threadNoun(threadType: ThreadType) { return threadType === threadTypes.SIDEBAR ? 'sidebar' : 'thread'; } function threadLabel(threadType: ThreadType) { if (threadType === threadTypes.CHAT_SECRET) { return 'Secret'; } else if (threadType === threadTypes.PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { return 'Sidebar'; } else if (threadType === threadTypes.PRIVATE) { return 'Private'; } else if (threadType === threadTypes.CHAT_NESTED_OPEN) { return 'Open'; } invariant(false, `unexpected threadType ${threadType}`); } function useWatchThread(threadInfo: ?ThreadInfo) { const dispatchActionPromise = useDispatchActionPromise(); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); const threadID = threadInfo?.id; const threadNotInChatList = !threadInChatList(threadInfo); React.useEffect(() => { if (threadID && threadNotInChatList) { threadWatcher.watchID(threadID); dispatchActionPromise( fetchMostRecentMessagesActionTypes, callFetchMostRecentMessages(threadID), ); } return () => { if (threadID && threadNotInChatList) { threadWatcher.removeID(threadID); } }; }, [ callFetchMostRecentMessages, dispatchActionPromise, threadNotInChatList, threadID, ]); } function useThreadCandidates(threadInfos: { [id: string]: ThreadInfo }) { return React.useMemo(() => { const infos = new Map(); for (const threadID in threadInfos) { const info = threadInfos[threadID]; if (info.parentThreadID || threadHasAdminRole(info)) { continue; } const key = getPendingThreadKey(info.members.map((member) => member.id)); const indexedThread = infos.get(key); if (!indexedThread || info.creationTime < indexedThread.creationTime) { infos.set(key, info); } } return infos; }, [threadInfos]); } function useSidebarCandidate(sourceMessageID: ?string) { return useSelector((state) => { if (!sourceMessageID) { return null; } return threadInfoFromSourceMessageIDSelector(state)[sourceMessageID]; }); } function useLatestThreadInfo( originalThreadInfo: ThreadInfo, searching: ?boolean, userInfoInputArray: $ReadOnlyArray, sourceMessageID: ?string, ) { const threadInfos = useSelector(threadInfoSelector); const threadCandidates = useThreadCandidates(threadInfos); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const userInfos = useSelector((state) => state.userStore.userInfos); const sidebarCandidate = useSidebarCandidate(sourceMessageID); return React.useMemo((): ?ThreadInfo => { const threadInfoFromParams = originalThreadInfo; const threadInfoFromStore = threadInfos[threadInfoFromParams.id]; if (threadInfoFromStore) { return threadInfoFromStore; } else if (!viewerID || !threadIsPending(threadInfoFromParams.id)) { return undefined; } const pendingThreadMemberIDs = searching ? [...userInfoInputArray.map((user) => user.id), viewerID] : threadInfoFromParams.members.map((member) => member.id); const threadKey = getPendingThreadKey(pendingThreadMemberIDs); if ( threadInfoFromParams.type !== threadTypes.SIDEBAR && threadCandidates.get(threadKey) ) { return threadCandidates.get(threadKey); } if (sidebarCandidate) { return sidebarCandidate; } const updatedThread = searching ? createPendingThread({ viewerID, threadType: pendingThreadType(userInfoInputArray.length), members: userInfoInputArray, }) : threadInfoFromParams; return { ...updatedThread, currentUser: getCurrentUser(updatedThread, viewerID, userInfos), }; }, [ originalThreadInfo, threadInfos, viewerID, searching, userInfoInputArray, threadCandidates, sidebarCandidate, userInfos, ]); } type ThreadTypeParentRequirement = 'optional' | 'required' | 'disabled'; function getThreadTypeParentRequirement( threadType: ThreadType, ): ThreadTypeParentRequirement { if ( threadType === threadTypes.CHAT_NESTED_OPEN || threadType === threadTypes.SIDEBAR ) { return 'required'; } else if ( threadType === threadTypes.PERSONAL || threadType === threadTypes.PRIVATE ) { return 'disabled'; } else { return 'optional'; } } function threadMemberHasPermission( threadInfo: ServerThreadInfo, memberID: string, permission: ThreadPermission, ): boolean { for (const member of threadInfo.members) { if (member.id !== memberID) { continue; } return permissionLookup(member.permissions, permission); } return false; } +function canCreateSidebar( + threadInfo: ThreadInfo, + messageCreatorRelationship: ?UserRelationshipStatus, +) { + const hasPermission = threadHasPermission( + threadInfo, + threadPermissions.CREATE_SIDEBARS, + ); + + const creatorRelationshipHasBlock = + messageCreatorRelationship && + relationshipBlockedInEitherDirection(messageCreatorRelationship); + + return hasPermission && !creatorRelationshipHasBlock; +} + +function useSidebarExistsOrCanBeCreated( + threadInfo: ThreadInfo, + messageItem: ChatMessageInfoItem, +) { + const messageCreatorUserInfo = useSelector( + (state) => state.userStore.userInfos[messageItem.messageInfo.creator.id], + ); + const canCreateSidebarFromMessage = canCreateSidebar( + threadInfo, + messageCreatorUserInfo.relationshipStatus, + ); + return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; +} + export { colorIsDark, generateRandomColor, generatePendingThreadColor, threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadIsGroupChat, threadIsPending, threadIsPersonalAndPending, getPendingThreadOtherUsers, getSingleOtherUser, getPendingThreadKey, createPendingThread, createPendingThreadItem, createPendingSidebar, pendingThreadType, useRealThreadCreator, getCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, robotextName, threadInfoFromRawThreadInfo, rawThreadInfoFromThreadInfo, threadTypeDescriptions, usersInThreadInfo, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadSearchText, threadNoun, threadLabel, useWatchThread, useSidebarCandidate, useLatestThreadInfo, getThreadTypeParentRequirement, threadMemberHasPermission, + useSidebarExistsOrCanBeCreated, }; diff --git a/web/chat/composed-message.react.js b/web/chat/composed-message.react.js index 82876911e..6bc010020 100644 --- a/web/chat/composed-message.react.js +++ b/web/chat/composed-message.react.js @@ -1,215 +1,230 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { Circle as CircleIcon, CheckCircle as CheckCircleIcon, XCircle as XCircleIcon, } from 'react-feather'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; +import { useSidebarExistsOrCanBeCreated } from 'lib/shared/thread-utils'; import { stringForUser } from 'lib/shared/user-utils'; import { assertComposableMessageType } from 'lib/types/message-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { type InputState, InputStateContext } from '../input/input-state'; import css from './chat-message-list.css'; import FailedSend from './failed-send.react'; import { InlineSidebar } from './inline-sidebar.react'; import { type OnMessagePositionInfo, type MessagePositionInfo, } from './message-position-types'; import MessageReplyTooltip from './message-reply-tooltip.react'; import SidebarTooltip from './sidebar-tooltip.react'; type BaseProps = {| +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +sendFailed: boolean, +setMouseOverMessagePosition: ( messagePositionInfo: MessagePositionInfo, ) => void, +mouseOverMessagePosition?: ?OnMessagePositionInfo, +canReply: boolean, +children: React.Node, +fixedWidth?: boolean, +borderRadius: number, |}; type BaseConfig = React.Config; type Props = {| ...BaseProps, + // Redux state + +sidebarExistsOrCanBeCreated: boolean, // withInputState +inputState: ?InputState, |}; class ComposedMessage extends React.PureComponent { static defaultProps = { borderRadius: 8, }; render() { 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.viewerMessageBoxContainer]: isViewer, [css.nonViewerMessageBoxContainer]: !isViewer, [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; if (!isViewer && item.startsCluster) { authorName = ( {stringForUser(creator)} ); } 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 viewerReplyTooltip, nonViewerReplyTooltip; if ( this.props.mouseOverMessagePosition && this.props.mouseOverMessagePosition.item.messageInfo.id === id && this.props.canReply ) { const { inputState } = this.props; invariant(inputState, 'inputState should be set in ComposedMessage'); const replyTooltip = ( ); if (isViewer) { viewerReplyTooltip = replyTooltip; } else { nonViewerReplyTooltip = replyTooltip; } } const positioning = isViewer ? 'right' : 'left'; let viewerSidebarTooltip, nonViewerSidebarTooltip; if ( this.props.mouseOverMessagePosition && this.props.mouseOverMessagePosition.item.messageInfo.id === id && - this.props.item.threadCreatedFromMessage + this.props.sidebarExistsOrCanBeCreated ) { const sidebarTooltip = ( ); + if (isViewer) { viewerSidebarTooltip = sidebarTooltip; } else { nonViewerSidebarTooltip = sidebarTooltip; } } let inlineSidebar = null; if (item.threadCreatedFromMessage) { inlineSidebar = (
); } return ( {authorName}
{viewerSidebarTooltip} {viewerReplyTooltip}
{this.props.children}
{nonViewerReplyTooltip} {nonViewerSidebarTooltip}
{deliveryIcon}
{failedSendInfo} {inlineSidebar}
); } onMouseEnter = (event: SyntheticEvent) => { const { item } = this.props; const rect = event.currentTarget.getBoundingClientRect(); const { top, bottom, left, right, height, width } = rect; const messagePosition = { top, bottom, left, right, height, width }; this.props.setMouseOverMessagePosition({ type: 'on', item, messagePosition, }); }; onMouseLeave = () => { const { item } = this.props; this.props.setMouseOverMessagePosition({ type: 'off', item }); }; } export default React.memo(function ConnectedComposedMessage( props: BaseConfig, ) { + const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( + props.threadInfo, + props.item, + ); const inputState = React.useContext(InputStateContext); - return ; + return ( + + ); }); diff --git a/web/chat/message.react.js b/web/chat/message.react.js index 6cfd91777..c74f21071 100644 --- a/web/chat/message.react.js +++ b/web/chat/message.react.js @@ -1,84 +1,85 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { messageTypes } from 'lib/types/message-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { longAbsoluteDate } from 'lib/utils/date-utils'; import css from './chat-message-list.css'; import { type OnMessagePositionInfo, type MessagePositionInfo, } from './message-position-types'; import MultimediaMessage from './multimedia-message.react'; import RobotextMessage from './robotext-message.react'; import TextMessage from './text-message.react'; type Props = {| item: ChatMessageInfoItem, threadInfo: ThreadInfo, setMouseOverMessagePosition: ( messagePositionInfo: MessagePositionInfo, ) => void, mouseOverMessagePosition: ?OnMessagePositionInfo, setModal: (modal: ?React.Node) => void, timeZone: ?string, |}; class Message extends React.PureComponent { render() { const { item, timeZone } = this.props; let conversationHeader = null; if (item.startsConversation) { conversationHeader = (
{longAbsoluteDate(item.messageInfo.time, timeZone)}
); } let message; if (item.messageInfo.type === messageTypes.TEXT) { message = ( ); } else if ( item.messageInfo.type === messageTypes.IMAGES || item.messageInfo.type === messageTypes.MULTIMEDIA ) { message = ( ); } else { invariant(item.robotext, "Flow can't handle our fancy types :("); message = ( ); } return (
{conversationHeader} {message}
); } } export default Message; diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js index 2689d0a44..a8730002b 100644 --- a/web/chat/robotext-message.react.js +++ b/web/chat/robotext-message.react.js @@ -1,169 +1,191 @@ // @flow import PropTypes from 'prop-types'; import * as React from 'react'; import { type RobotextChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { splitRobotext, parseRobotextEntity } from 'lib/shared/message-utils'; +import { useSidebarExistsOrCanBeCreated } from 'lib/shared/thread-utils'; import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; import type { DispatchActionPayload } from 'lib/utils/action-utils'; import { connect } from 'lib/utils/redux-utils'; import Markdown from '../markdown/markdown.react'; import { linkRules } from '../markdown/rules.react'; import { type AppState, updateNavInfoActionType } from '../redux/redux-setup'; import css from './chat-message-list.css'; import { InlineSidebar } from './inline-sidebar.react'; import type { MessagePositionInfo, OnMessagePositionInfo, } from './message-position-types'; import SidebarTooltip from './sidebar-tooltip.react'; -type Props = {| +type BaseProps = {| +item: RobotextChatMessageInfoItem, + +threadInfo: ThreadInfo, +setMouseOverMessagePosition: ( messagePositionInfo: MessagePositionInfo, ) => void, +mouseOverMessagePosition: ?OnMessagePositionInfo, |}; +type Props = {| + ...BaseProps, + // Redux state + +sidebarExistsOrCanBeCreated: boolean, +|}; class RobotextMessage extends React.PureComponent { render() { let inlineSidebar; if (this.props.item.threadCreatedFromMessage) { inlineSidebar = (
); } - const { id } = this.props.item.messageInfo; + const { item, threadInfo, sidebarExistsOrCanBeCreated } = this.props; + const { id } = item.messageInfo; let sidebarTooltip; if ( this.props.mouseOverMessagePosition && this.props.mouseOverMessagePosition.item.messageInfo.id === id && - this.props.item.threadCreatedFromMessage + sidebarExistsOrCanBeCreated ) { sidebarTooltip = ( ); } return (
{this.linkedRobotext()} {sidebarTooltip}
{inlineSidebar}
); } linkedRobotext() { const { item } = this.props; const { robotext } = item; const robotextParts = splitRobotext(robotext); const textParts = []; let keyIndex = 0; for (let splitPart of robotextParts) { if (splitPart === '') { continue; } if (splitPart.charAt(0) !== '<') { const key = `text${keyIndex++}`; textParts.push( {decodeURI(splitPart)} , ); continue; } const { rawText, entityType, id } = parseRobotextEntity(splitPart); if (entityType === 't' && id !== item.messageInfo.threadID) { textParts.push(); } else if (entityType === 'c') { textParts.push(); } else { textParts.push(rawText); } } return textParts; } onMouseEnter = (event: SyntheticEvent) => { const { item } = this.props; const rect = event.currentTarget.getBoundingClientRect(); const { top, bottom, left, right, height, width } = rect; const messagePosition = { top, bottom, left, right, height, width }; this.props.setMouseOverMessagePosition({ type: 'on', item, messagePosition, }); }; onMouseLeave = () => { const { item } = this.props; this.props.setMouseOverMessagePosition({ type: 'off', item }); }; } type InnerThreadEntityProps = { id: string, name: string, // Redux state threadInfo: ThreadInfo, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, }; class InnerThreadEntity extends React.PureComponent { static propTypes = { id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, threadInfo: threadInfoPropType.isRequired, dispatchActionPayload: PropTypes.func.isRequired, }; render() { return {this.props.name}; } onClickThread = (event: SyntheticEvent) => { event.preventDefault(); const id = this.props.id; this.props.dispatchActionPayload(updateNavInfoActionType, { activeChatThreadID: id, }); }; } const ThreadEntity = connect( (state: AppState, ownProps: { id: string }) => ({ threadInfo: threadInfoSelector(state)[ownProps.id], }), null, true, )(InnerThreadEntity); function ColorEntity(props: {| color: string |}) { const colorStyle = { color: props.color }; return {props.color}; } -export default RobotextMessage; +export default React.memo(function ConnectedRobotextMessage( + props: BaseProps, +) { + const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( + props.threadInfo, + props.item, + ); + return ( + + ); +}); diff --git a/web/chat/sidebar-tooltip.react.js b/web/chat/sidebar-tooltip.react.js index eaf1fed80..43d1f8590 100644 --- a/web/chat/sidebar-tooltip.react.js +++ b/web/chat/sidebar-tooltip.react.js @@ -1,82 +1,156 @@ // @flow import { faEllipsisH } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import * as React from 'react'; +import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; +import { + type ComposableMessageInfo, + type RobotextMessageInfo, +} from 'lib/types/message-types'; import type { ThreadInfo } from 'lib/types/thread-types'; -import { useOnClickThread } from '../selectors/nav-selectors'; +import { + useOnClickThread, + useOnClickPendingSidebar, +} from '../selectors/nav-selectors'; import css from './chat-message-list.css'; type Props = {| - +threadCreatedFromMessage: ThreadInfo, - +onClick: () => void, + +onLeave: () => void, + +onButtonClick: (event: SyntheticEvent) => void, +messagePosition: 'left' | 'center' | 'right', + +buttonText: string, |}; -function SidebarTooltip(props: Props) { - const { onClick, threadCreatedFromMessage, messagePosition } = props; +function SidebarTooltipButton(props: Props) { + const { onLeave, onButtonClick, messagePosition, buttonText } = props; const [tooltipVisible, setTooltipVisible] = React.useState(false); - const onButtonClick = useOnClickThread(threadCreatedFromMessage.id); const toggleMenu = React.useCallback(() => { setTooltipVisible(!tooltipVisible); }, [tooltipVisible]); const toggleSidebar = React.useCallback( (event: SyntheticEvent) => { onButtonClick(event); - onClick(); + onLeave(); }, - [onClick, onButtonClick], + [onLeave, onButtonClick], ); const hideMenu = React.useCallback(() => { setTooltipVisible(false); }, []); const sidebarMenuClassName = classNames({ [css.menuSidebarContent]: true, [css.menuSidebarContentVisible]: tooltipVisible, [css.menuSidebarNonViewerContent]: messagePosition === 'left', [css.menuSidebarCenterContent]: messagePosition === 'center', [css.messageTimestampBottomRightTooltip]: messagePosition !== 'left', [css.messageTimestampBottomLeftTooltip]: messagePosition === 'left', }); const sidebarTooltipClassName = classNames({ [css.messageSidebarTooltip]: true, [css.viewerMessageSidebarTooltip]: messagePosition === 'right', [css.tooltipRightPadding]: messagePosition === 'right', [css.tooltipLeftPadding]: messagePosition !== 'right', }); const sidebarIconClassName = classNames({ [css.messageTooltipIcon]: true, [css.tooltipRightPadding]: messagePosition === 'left', [css.tooltipLeftPadding]: messagePosition === 'right', [css.tooltipLeftRightPadding]: messagePosition === 'center', }); return (
  • - +
); } +type OpenSidebarProps = {| + +threadCreatedFromMessage: ThreadInfo, + +onLeave: () => void, + +messagePosition: 'left' | 'center' | 'right', +|}; +function OpenSidebar(props: OpenSidebarProps) { + const onButtonClick = useOnClickThread(props.threadCreatedFromMessage.id); + return ( + + ); +} + +type CreateSidebarProps = {| + +threadInfo: ThreadInfo, + +messageInfo: ComposableMessageInfo | RobotextMessageInfo, + +onLeave: () => void, + +messagePosition: 'left' | 'center' | 'right', +|}; +function CreateSidebar(props: CreateSidebarProps) { + const onButtonClick = useOnClickPendingSidebar( + props.messageInfo, + props.threadInfo, + ); + return ( + + ); +} + +type SidebarTooltipProps = {| + +threadInfo: ThreadInfo, + +item: ChatMessageInfoItem, + +onLeave: () => void, + +messagePosition: 'left' | 'center' | 'right', +|}; +function SidebarTooltip(props: SidebarTooltipProps) { + const { threadInfo, item, onLeave, messagePosition } = props; + if (item.threadCreatedFromMessage) { + return ( + + ); + } else { + return ( + + ); + } +} + export default SidebarTooltip;