diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 2710764ad..2eb05fee0 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1031 +1,1046 @@ // @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 { 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 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) { return [...memberIDs].sort().join('+'); } type CreatePendingThreadArgs = {| +viewerID: string, +threadType: ThreadType, +members?: $ReadOnlyArray, +parentThreadID?: ?string, +threadColor?: ?string, +name?: ?string, |}; function createPendingThread({ viewerID, threadType, members, parentThreadID, threadColor, name, }: CreatePendingThreadArgs) { const now = Date.now(); members = members ?? []; const memberIDs = members.map((member) => member.id); const threadID = `pending/${getPendingThreadKey(memberIDs)}`; 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, }; 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, }); } 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 threadInfo = thread?.threadInfo; const serverThreadID = React.useRef(null); const sourceMessageID = thread?.sourceMessageID; const dispatchActionPromise = useDispatchActionPromise(); const callNewThread = useServerCall(newThread); return React.useCallback(async () => { const threadID = threadInfo?.id; const isThreadCreated = threadID && !threadIsPending(threadID); if (isThreadCreated || !threadInfo) { return threadID; } if (!serverThreadID.current) { serverThreadID.current = await createRealThreadFromPendingThread( threadInfo, dispatchActionPromise, callNewThread, sourceMessageID, handleError, ); } return serverThreadID.current; }, [ callNewThread, dispatchActionPromise, handleError, 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; +} + 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, }; diff --git a/server/src/updaters/thread-updaters.js b/server/src/updaters/thread-updaters.js index c7a228b8a..f12d0f0f2 100644 --- a/server/src/updaters/thread-updaters.js +++ b/server/src/updaters/thread-updaters.js @@ -1,736 +1,721 @@ // @flow import invariant from 'invariant'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors'; import { threadHasAdminRole, roleIsAdminRole, viewerIsMember, getThreadTypeParentRequirement, + threadMemberHasPermission, } from 'lib/shared/thread-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import type { Shape } from 'lib/types/core'; import { messageTypes, defaultNumberPerThread } from 'lib/types/message-types'; import { userRelationshipStatus } from 'lib/types/relationship-types'; import { type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerThreadJoinRequest, type ThreadJoinResult, threadPermissions, threadTypes, - assertThreadType, } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import createMessages from '../creators/message-creator'; import { createUpdates } from '../creators/update-creator'; import { dbQuery, SQL } from '../database/database'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; -import { fetchThreadInfos } from '../fetchers/thread-fetchers'; +import { + fetchThreadInfos, + fetchServerThreadInfos, +} from '../fetchers/thread-fetchers'; import { checkThreadPermission, viewerIsMember as fetchViewerIsMember, checkThread, } from '../fetchers/thread-permission-fetchers'; import { verifyUserIDs, verifyUserOrCookieIDs, fetchKnownUserInfos, } from '../fetchers/user-fetchers'; import type { Viewer } from '../session/viewer'; import { updateRoles } from './role-updaters'; import { changeRole, recalculateAllPermissions, commitMembershipChangeset, setJoinsToUnread, getParentThreadRelationshipRowsForNewUsers, } from './thread-permission-updaters'; async function updateRole( viewer: Viewer, request: RoleChangeRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT user, role FROM memberships WHERE user IN (${memberIDs}) AND thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonMemberUser = false; let numResults = 0; for (let row of result) { if (row.role <= 0) { nonMemberUser = true; break; } numResults++; } if (nonMemberUser || numResults < memberIDs.length) { throw new ServerError('invalid_parameters'); } const changeset = await changeRole(request.threadID, memberIDs, request.role); if (!changeset) { throw new ServerError('unknown_error'); } const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.CHANGE_ROLE, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), userIDs: memberIDs, newRole: request.role, }; const newMessageInfos = await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function removeMembers( viewer: Viewer, request: RemoveMembersRequest, ): Promise { const viewerID = viewer.userID; if (request.memberIDs.includes(viewerID)) { throw new ServerError('invalid_parameters'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserOrCookieIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.REMOVE_MEMBERS, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT m.user, m.role, t.default_role FROM memberships m LEFT JOIN threads t ON t.id = m.thread WHERE m.user IN (${memberIDs}) AND m.thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonDefaultRoleUser = false; const actualMemberIDs = []; for (let row of result) { if (row.role <= 0) { continue; } actualMemberIDs.push(row.user.toString()); if (row.role !== row.default_role) { nonDefaultRoleUser = true; } } if (nonDefaultRoleUser) { const hasChangeRolePermission = await checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ); if (!hasChangeRolePermission) { throw new ServerError('invalid_credentials'); } } const changeset = await changeRole(request.threadID, actualMemberIDs, 0); if (!changeset) { throw new ServerError('unknown_error'); } const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.REMOVE_MEMBERS, threadID: request.threadID, creatorID: viewerID, time: Date.now(), removedUserIDs: actualMemberIDs, }; const newMessageInfos = await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function leaveThread( viewer: Viewer, request: LeaveThreadRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [fetchThreadResult, hasPermission] = await Promise.all([ fetchThreadInfos(viewer, SQL`t.id = ${request.threadID}`), checkThreadPermission( viewer, request.threadID, threadPermissions.LEAVE_THREAD, ), ]); const threadInfo = fetchThreadResult.threadInfos[request.threadID]; if (!viewerIsMember(threadInfo) || !hasPermission) { throw new ServerError('invalid_parameters'); } const viewerID = viewer.userID; if (threadHasAdminRole(threadInfo)) { let otherUsersExist = false; let otherAdminsExist = false; for (let member of threadInfo.members) { const role = member.role; if (!role || member.id === viewerID) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo.roles[role])) { otherAdminsExist = true; break; } } if (otherUsersExist && !otherAdminsExist) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewerID], 0); if (!changeset) { throw new ServerError('unknown_error'); } const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.LEAVE_THREAD, threadID: request.threadID, creatorID: viewerID, time: Date.now(), }; await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates } }; } return { threadInfos, updatesResult: { newUpdates: viewerUpdates, }, }; } type UpdateThreadOptions = Shape<{| +forceAddMembers: boolean, +forceUpdateRoot: boolean, |}>; async function updateThread( viewer: Viewer, request: UpdateThreadRequest, options?: UpdateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const forceUpdateRoot = options?.forceUpdateRoot ?? false; const validationPromises = {}; const changedFields = {}; const sqlUpdate = {}; const { name } = request.changes; if (name !== undefined && name !== null) { changedFields.name = name; sqlUpdate.name = name ?? null; } const { description } = request.changes; if (description !== undefined && description !== null) { changedFields.description = description; sqlUpdate.description = description ?? null; } if (request.changes.color) { const color = request.changes.color.toLowerCase(); changedFields.color = color; sqlUpdate.color = color; } const { parentThreadID } = request.changes; if (parentThreadID !== undefined) { // TODO some sort of message when this changes sqlUpdate.parent_thread_id = parentThreadID; } const threadType = request.changes.type; if (threadType !== null && threadType !== undefined) { changedFields.type = threadType; sqlUpdate.type = threadType; } if ( !viewer.isScriptViewer && (threadType === threadTypes.PERSONAL || threadType === threadTypes.PRIVATE) ) { throw new ServerError('invalid_parameters'); } const newMemberIDs = request.changes.newMemberIDs && request.changes.newMemberIDs.length > 0 ? [...request.changes.newMemberIDs] : null; if (Object.keys(sqlUpdate).length === 0 && !newMemberIDs) { throw new ServerError('invalid_parameters'); } if (newMemberIDs) { validationPromises.fetchNewMembers = fetchKnownUserInfos( viewer, newMemberIDs, ); } - const validationQuery = SQL` - SELECT type, parent_thread_id - FROM threads - WHERE id = ${request.threadID} - `; - validationPromises.validationQuery = dbQuery(validationQuery); + validationPromises.serverThreadInfos = fetchServerThreadInfos( + SQL`t.id = ${request.threadID}`, + ); const checks = []; if ( sqlUpdate.name !== undefined || sqlUpdate.description !== undefined || sqlUpdate.color !== undefined ) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD, }); } if (parentThreadID !== undefined || sqlUpdate.type !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_PERMISSIONS, }); } if (newMemberIDs) { checks.push({ check: 'permission', permission: threadPermissions.ADD_MEMBERS, }); } validationPromises.hasNecessaryPermissions = checkThread( viewer, request.threadID, checks, ); const { fetchNewMembers, - validationQuery: [validationResult], + serverThreadInfos, hasNecessaryPermissions, } = await promiseAll(validationPromises); - if (validationResult.length === 0) { + const serverThreadInfo = serverThreadInfos.threadInfos[request.threadID]; + if (!serverThreadInfo) { throw new ServerError('internal_error'); } - const validationRow = validationResult[0]; if (!hasNecessaryPermissions) { throw new ServerError('invalid_credentials'); } - const oldThreadType = assertThreadType(validationRow.type); - const oldParentThreadID = validationRow.parent_thread_id - ? validationRow.parent_thread_id.toString() - : null; + const oldThreadType = serverThreadInfo.type; + const oldParentThreadID = serverThreadInfo.parentThreadID; const nextThreadType = threadType !== null && threadType !== undefined ? threadType : oldThreadType; let nextParentThreadID = parentThreadID !== undefined ? parentThreadID : oldParentThreadID; // Does the new thread type preclude a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'disabled' && nextParentThreadID !== null ) { nextParentThreadID = null; sqlUpdate.parent_thread_id = null; } // Does the new thread type require a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'required' && nextParentThreadID === null ) { throw new ServerError('no_parent_thread_specified'); } const threadRootChanged = forceUpdateRoot || nextParentThreadID !== oldParentThreadID || nextThreadType !== oldThreadType; - let parentThreadMembers; - if (nextParentThreadID) { - const promises = [ - fetchThreadInfos(viewer, SQL`t.id = ${nextParentThreadID}`), - ]; - - if (threadRootChanged) { - promises.push( - checkThreadPermission( - viewer, - nextParentThreadID, - nextThreadType === threadTypes.SIDEBAR - ? threadPermissions.CREATE_SIDEBARS - : threadPermissions.CREATE_SUBTHREADS, - ), - ); - } - - const [parentFetchResult, hasParentPermission] = await Promise.all( - promises, + if (threadRootChanged && nextParentThreadID) { + const hasParentPermission = await checkThreadPermission( + viewer, + nextParentThreadID, + nextThreadType === threadTypes.SIDEBAR + ? threadPermissions.CREATE_SIDEBARS + : threadPermissions.CREATE_SUBTHREADS, ); - - const parentThreadInfo = parentFetchResult.threadInfos[nextParentThreadID]; - if (!parentThreadInfo) { + if (!hasParentPermission) { throw new ServerError('invalid_parameters'); } - if (threadRootChanged && !hasParentPermission) { - throw new ServerError('invalid_parameters'); - } - parentThreadMembers = parentThreadInfo.members.map( - (userInfo) => userInfo.id, - ); } if (fetchNewMembers) { invariant(newMemberIDs, 'should be set'); const newIDs = newMemberIDs; // for Flow for (const newMemberID of newIDs) { if (!fetchNewMembers[newMemberID]) { if (!forceAddMembers) { throw new ServerError('invalid_credentials'); } else if (nextThreadType === threadTypes.SIDEBAR) { throw new ServerError('invalid_thread_type'); } else { continue; } } const { relationshipStatus } = fetchNewMembers[newMemberID]; if ( relationshipStatus === userRelationshipStatus.FRIEND && nextThreadType !== threadTypes.SIDEBAR ) { continue; } else if ( - parentThreadMembers && - parentThreadMembers.includes(newMemberID) && - relationshipStatus !== userRelationshipStatus.BLOCKED_BY_VIEWER && - relationshipStatus !== userRelationshipStatus.BLOCKED_VIEWER && - relationshipStatus !== userRelationshipStatus.BOTH_BLOCKED + relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER || + relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER || + relationshipStatus === userRelationshipStatus.BOTH_BLOCKED ) { + throw new ServerError('invalid_credentials'); + } else if ( + threadMemberHasPermission( + serverThreadInfo, + newMemberID, + threadPermissions.KNOW_OF, + ) + ) { + continue; + } else if (forceAddMembers && nextThreadType !== threadTypes.SIDEBAR) { continue; } throw new ServerError('invalid_credentials'); } } const intermediatePromises = {}; if (Object.keys(sqlUpdate).length > 0) { const updateQuery = SQL` UPDATE threads SET ${sqlUpdate} WHERE id = ${request.threadID} `; intermediatePromises.updateQuery = dbQuery(updateQuery); } if (newMemberIDs) { intermediatePromises.addMembersChangeset = changeRole( request.threadID, newMemberIDs, null, ); } if (threadRootChanged) { intermediatePromises.recalculatePermissionsChangeset = (async () => { if (forceUpdateRoot || nextThreadType !== oldThreadType) { await updateRoles(viewer, request.threadID, nextThreadType); } return await recalculateAllPermissions(request.threadID, nextThreadType); })(); } const { addMembersChangeset, recalculatePermissionsChangeset, } = await promiseAll(intermediatePromises); const membershipRows = []; const relationshipRows = []; if (recalculatePermissionsChangeset && newMemberIDs) { const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); const parentRelationshipRows = getParentThreadRelationshipRowsForNewUsers( request.threadID, recalculateMembershipRows, newMemberIDs, ); relationshipRows.push( ...recalculateRelationshipRows, ...parentRelationshipRows, ); } else if (recalculatePermissionsChangeset) { const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); relationshipRows.push(...recalculateRelationshipRows); } if (addMembersChangeset) { const { membershipRows: addMembersMembershipRows, relationshipRows: addMembersRelationshipRows, } = addMembersChangeset; relationshipRows.push(...addMembersRelationshipRows); setJoinsToUnread(addMembersMembershipRows, viewer.userID, request.threadID); membershipRows.push(...addMembersMembershipRows); } const changeset = { membershipRows, relationshipRows }; const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, { // This forces an update for this thread, // regardless of whether any membership rows are changed changedThreadIDs: Object.keys(sqlUpdate).length > 0 ? new Set([request.threadID]) : new Set(), }, ); const time = Date.now(); const messageDatas = []; for (let fieldName in changedFields) { const newValue = changedFields[fieldName]; messageDatas.push({ type: messageTypes.CHANGE_SETTINGS, threadID: request.threadID, creatorID: viewer.userID, time, field: fieldName, value: newValue, }); } if (newMemberIDs) { messageDatas.push({ type: messageTypes.ADD_MEMBERS, threadID: request.threadID, creatorID: viewer.userID, time, addedUserIDs: newMemberIDs, }); } const newMessageInfos = await createMessages(viewer, messageDatas); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function joinThread( viewer: Viewer, request: ServerThreadJoinRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [isMember, hasPermission] = await Promise.all([ fetchViewerIsMember(viewer, request.threadID), checkThreadPermission( viewer, request.threadID, threadPermissions.JOIN_THREAD, ), ]); if (isMember || !hasPermission) { throw new ServerError('invalid_parameters'); } const { calendarQuery } = request; if (calendarQuery) { const threadFilterIDs = filteredThreadIDs(calendarQuery.filters); if ( !threadFilterIDs || threadFilterIDs.size !== 1 || threadFilterIDs.values().next().value !== request.threadID ) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewer.userID], null); if (!changeset) { throw new ServerError('unknown_error'); } setJoinsToUnread(changeset.membershipRows, viewer.userID, request.threadID); const membershipResult = await commitMembershipChangeset(viewer, changeset, { calendarQuery, }); const messageData = { type: messageTypes.JOIN_THREAD, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), }; await createMessages(viewer, [messageData]); const threadSelectionCriteria = { threadCursors: { [request.threadID]: false }, }; const [fetchMessagesResult, fetchEntriesResult] = await Promise.all([ fetchMessageInfos(viewer, threadSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, ]); const rawEntryInfos = fetchEntriesResult && fetchEntriesResult.rawEntryInfos; const response: ThreadJoinResult = { rawMessageInfos: fetchMessagesResult.rawMessageInfos, truncationStatuses: fetchMessagesResult.truncationStatuses, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; if (!hasMinCodeVersion(viewer.platformDetails, 62)) { response.threadInfos = membershipResult.threadInfos; } if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } async function updateThreadMembers(viewer: Viewer) { const { threadInfos } = await fetchThreadInfos( viewer, SQL`t.parent_thread_id IS NOT NULL `, ); const updateDatas = []; const time = Date.now(); for (const threadID in threadInfos) { updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID: viewer.id, time, threadID: threadID, targetSession: viewer.session, }); } await createUpdates(updateDatas); } export { updateRole, removeMembers, leaveThread, updateThread, joinThread, updateThreadMembers, };