diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index c767df2eb..1c1880693 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,802 +1,807 @@ // @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 { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions'; import type { ChatThreadItem } from '../selectors/chat-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 UpdateInfo, updateTypes } from '../types/update-types'; import type { GlobalAccountUserInfo, UserInfos, UserInfo, } from '../types/user-types'; import { useDispatchActionPromise, useServerCall } from '../utils/action-utils'; import { pluralize } 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, - 'Pending thread should contain other members id in its id', + 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; 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: [initialMemberUserInfo], + members: creatorIsMember ? [initialMemberUserInfo] : [], parentThreadID, threadColor: color, name: getMessageTitle(messageInfo, threadInfo, markdownRules), }); } function pendingThreadType(numberOfOtherMembers: number) { return numberOfOtherMembers === 1 ? threadTypes.PERSONAL : threadTypes.CHAT_SECRET; } 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, ]); } export { colorIsDark, generateRandomColor, generatePendingThreadColor, threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadIsGroupChat, threadIsPending, threadIsPersonalAndPending, getPendingThreadOtherUsers, getSingleOtherUser, getPendingThreadKey, createPendingThread, createPendingThreadItem, createPendingSidebar, pendingThreadType, getCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, robotextName, threadInfoFromRawThreadInfo, rawThreadInfoFromThreadInfo, threadTypeDescriptions, usersInThreadInfo, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadSearchText, threadNoun, threadLabel, useWatchThread, }; diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index a2fea51cf..10030b97d 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,952 +1,956 @@ // @flow import invariant from 'invariant'; import _throttle from 'lodash/throttle'; import PropTypes from 'prop-types'; import * as React from 'react'; import { View, TextInput, TouchableOpacity, Platform, Text, ActivityIndicator, TouchableWithoutFeedback, Alert, NativeAppEventEmitter, } from 'react-native'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import Animated, { Easing } from 'react-native-reanimated'; import FAIcon from 'react-native-vector-icons/FontAwesome'; import Icon from 'react-native-vector-icons/Ionicons'; import { useDispatch } from 'react-redux'; import { saveDraftActionType } from 'lib/actions/miscellaneous-action-types'; import { joinThreadActionTypes, joinThread, newThread, newThreadActionTypes, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { trimMessage } from 'lib/shared/message-utils'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, threadIsPending, getPendingThreadOtherUsers, pendingThreadType, } from 'lib/shared/thread-utils'; import type { CalendarQuery } from 'lib/types/entry-types'; import { loadingStatusPropType } from 'lib/types/loading-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { PhotoPaste } from 'lib/types/media-types'; import { messageTypes } from 'lib/types/message-types'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo, threadInfoPropType, threadPermissions, type ClientThreadJoinRequest, type ThreadJoinPayload, type NewThreadRequest, type NewThreadResult, threadTypes, } from 'lib/types/thread-types'; import { type UserInfos, userInfoPropType } from 'lib/types/user-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import Button from '../components/button.react'; import ClearableTextInput from '../components/clearable-text-input.react'; import { type InputState, inputStatePropType, InputStateContext, } from '../input/input-state'; import { getKeyboardHeight } from '../keyboard/keyboard'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react'; import { type KeyboardState, keyboardStatePropType, KeyboardContext, } from '../keyboard/keyboard-state'; import { nonThreadCalendarQuery, activeThreadSelector, } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { type NavigationRoute, CameraModalRouteName, ImagePasteModalRouteName, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type Colors, colorsPropType, useStyles, useColors, } from '../themes/colors'; import type { ViewStyle } from '../types/styles'; import { runTiming } from '../utils/animation-utils'; import type { ChatNavigationProp } from './chat.react'; import { messageListRoutePropType, messageListNavPropType, } from './message-list-types'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, cond, neq, sub, interpolate, stopClock, } = Animated; /* eslint-enable import/no-named-as-default-member */ const expandoButtonsAnimationConfig = { duration: 500, easing: Easing.inOut(Easing.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: Easing.inOut(Easing.ease), }; const draftKeyFromThreadID = (threadID: string) => `${threadID}/message_composer`; type BaseProps = {| +threadInfo: ThreadInfo, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, |}; type Props = {| ...BaseProps, // Redux state +viewerID: ?string, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +userInfos: UserInfos, +colors: Colors, +styles: typeof unboundStyles, // connectNav +isActive: boolean, // withKeyboardState +keyboardState: ?KeyboardState, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +joinThread: (request: ClientThreadJoinRequest) => Promise, +newThread: (request: NewThreadRequest) => Promise, // withInputState +inputState: ?InputState, |}; type State = {| +text: string, +buttonsExpanded: boolean, |}; class ChatInputBar extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, navigation: messageListNavPropType.isRequired, route: messageListRoutePropType.isRequired, isActive: PropTypes.bool.isRequired, viewerID: PropTypes.string, draft: PropTypes.string.isRequired, joinThreadLoadingStatus: loadingStatusPropType.isRequired, calendarQuery: PropTypes.func.isRequired, nextLocalID: PropTypes.number.isRequired, userInfos: PropTypes.objectOf(userInfoPropType).isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, keyboardState: keyboardStatePropType, dispatch: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, joinThread: PropTypes.func.isRequired, inputState: inputStatePropType, newThread: PropTypes.func.isRequired, }; textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: ViewStyle; cameraRollIconStyle: ViewStyle; cameraIconStyle: ViewStyle; expandIconStyle: ViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: ViewStyle; newThreadID: ?string; constructor(props: Props) { super(props); this.state = { text: props.draft, buttonsExpanded: true, }; this.expandoButtonsOpen = new Value(1); this.targetExpandoButtonsOpen = new Value(1); const prevTargetExpandoButtonsOpen = new Value(1); const expandoButtonClock = new Clock(); const expandoButtonsOpen = block([ cond(neq(this.targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [ stopClock(expandoButtonClock), set(prevTargetExpandoButtonsOpen, this.targetExpandoButtonsOpen), ]), cond( neq(this.expandoButtonsOpen, this.targetExpandoButtonsOpen), set( this.expandoButtonsOpen, runTiming( expandoButtonClock, this.expandoButtonsOpen, this.targetExpandoButtonsOpen, true, expandoButtonsAnimationConfig, ), ), ), this.expandoButtonsOpen, ]); this.cameraRollIconStyle = { ...unboundStyles.cameraRollIcon, opacity: expandoButtonsOpen, }; this.cameraIconStyle = { ...unboundStyles.cameraIcon, opacity: expandoButtonsOpen, }; const expandoButtonsWidth = interpolate(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [22, 60], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; const initialSendButtonContainerOpen = trimMessage(props.draft) ? 1 : 0; this.sendButtonContainerOpen = new Value(initialSendButtonContainerOpen); this.targetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const prevTargetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const sendButtonClock = new Clock(); const sendButtonContainerOpen = block([ cond( neq( this.targetSendButtonContainerOpen, prevTargetSendButtonContainerOpen, ), [ stopClock(sendButtonClock), set( prevTargetSendButtonContainerOpen, this.targetSendButtonContainerOpen, ), ], ), cond( neq(this.sendButtonContainerOpen, this.targetSendButtonContainerOpen), set( this.sendButtonContainerOpen, runTiming( sendButtonClock, this.sendButtonContainerOpen, this.targetSendButtonContainerOpen, true, sendButtonAnimationConfig, ), ), ), this.sendButtonContainerOpen, ]); const sendButtonContainerWidth = interpolate(sendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }); this.sendButtonContainerStyle = { width: sendButtonContainerWidth }; } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing() { return ChatInputBar.systemKeyboardShowing(this.props); } immediatelyShowSendButton() { this.sendButtonContainerOpen.setValue(1); this.targetSendButtonContainerOpen.setValue(1); } updateSendButton(currentText: string) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } componentDidMount() { if (this.props.isActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.isActive && !prevProps.isActive) { this.addReplyListener(); } else if (!this.props.isActive && prevProps.isActive) { this.removeReplyListener(); } const currentText = trimMessage(this.state.text); const prevText = trimMessage(prevState.text); if ( (currentText === '' && prevText !== '') || (currentText !== '' && prevText === '') ) { this.updateSendButton(currentText); } const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing( this.props, ); const systemKeyboardWasShowing = ChatInputBar.systemKeyboardShowing( prevProps, ); if (systemKeyboardIsShowing && !systemKeyboardWasShowing) { this.hideButtons(); } else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) { this.expandButtons(); } const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props); const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps); if (!imageGalleryIsOpen && imageGalleryWasOpen) { this.hideButtons(); } else if (imageGalleryIsOpen && !imageGalleryWasOpen) { this.expandButtons(); this.setIOSKeyboardHeight(); } } addReplyListener() { invariant( this.props.inputState, 'inputState should be set in addReplyListener', ); this.props.inputState.addReplyListener(this.focusAndUpdateText); } removeReplyListener() { invariant( this.props.inputState, 'inputState should be set in removeReplyListener', ); this.props.inputState.removeReplyListener(this.focusAndUpdateText); } setIOSKeyboardHeight() { if (Platform.OS !== 'ios') { return; } const { textInput } = this; if (!textInput) { return; } const keyboardHeight = getKeyboardHeight(); if (keyboardHeight === null || keyboardHeight === undefined) { return; } TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; if (!isMember && canJoin) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { buttonContent = ( Join Thread ); } joinButton = ( ); } let content; if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { content = this.renderInput(); } else if ( threadFrozenDueToViewerBlock( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ) && threadActualMembers(this.props.threadInfo.members).length === 2 ) { content = ( You can't send messages to a user that you've blocked. ); } else if (isMember) { content = ( You don't have permission to send messages. ); } else { const defaultRoleID = Object.keys(this.props.threadInfo.roles).find( (roleID) => this.props.threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = this.props.threadInfo.roles[defaultRoleID]; const membersAreVoiced = !!defaultRole.permissions[ threadPermissions.VOICED ]; if (membersAreVoiced && canJoin) { content = ( Join this thread to send messages. ); } else { content = ( You don't have permission to send messages. ); } } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); return ( {joinButton} {content} {keyboardInputHost} ); } renderInput() { const expandoButton = ( ); const threadColor = `#${this.props.threadInfo.color}`; return ( {this.state.buttonsExpanded ? expandoButton : null} {this.state.buttonsExpanded ? null : expandoButton} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => { this.clearableTextInput = clearableTextInput; }; updateText = (text: string) => { this.setState({ text }); this.saveDraft(text); }; saveDraft = _throttle((text: string) => { this.props.dispatch({ type: saveDraftActionType, payload: { key: draftKeyFromThreadID(this.props.threadInfo.id), draft: text, }, }); }, 400); focusAndUpdateText = (text: string) => { const currentText = this.state.text; if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); this.immediatelyShowSendButton(); this.immediatelyHideButtons(); } invariant(this.textInput, 'textInput should be set in focusAndUpdateText'); this.textInput.focus(); }; getServerThreadID = async () => { if (this.newThreadID) { return this.newThreadID; } const { threadInfo } = this.props; 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 = this.props.newThread({ type: pendingThreadType(otherMemberIDs.length), initialMemberIDs: otherMemberIDs, color: threadInfo.color, }); } else { const sourceMessageID = this.props.route.params.sidebarSourceMessageID; invariant( sourceMessageID, - 'sourceMessageID should be set getServerThreadID for sidebar', + 'sourceMessageID should be set in getServerThreadID for sidebar', ); resultPromise = this.props.newThread({ type: threadTypes.SIDEBAR, initialMemberIDs: otherMemberIDs, color: threadInfo.color, sourceMessageID, parentThreadID: threadInfo.parentThreadID, name: threadInfo.name, }); } this.props.dispatchActionPromise(newThreadActionTypes, resultPromise); const { newThreadID } = await resultPromise; this.newThreadID = newThreadID; return newThreadID; } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: false, }); } return undefined; }; onSend = async () => { if (!trimMessage(this.state.text)) { return; } this.updateSendButton(''); const { clearableTextInput } = this; invariant( clearableTextInput, 'clearableTextInput should be sent in onSend', ); let text = await clearableTextInput.getValueAndReset(); text = trimMessage(text); if (!text) { return; } const localID = `local${this.props.nextLocalID}`; const creatorID = this.props.viewerID; const threadID = await this.getServerThreadID(); invariant(creatorID, 'should have viewer ID in order to send a message'); invariant( this.props.inputState, 'inputState should be set in ChatInputBar.onSend', ); if (threadID) { this.props.inputState.sendTextMessage({ type: messageTypes.TEXT, localID, threadID, text, creatorID, time: Date.now(), }); } }; onPressJoin = () => { this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction()); }; async joinAction() { const query = this.props.calendarQuery(); return await this.props.joinThread({ threadID: this.props.threadInfo.id, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [this.props.threadInfo.id] }, ], }, }); } expandButtons = () => { if (this.state.buttonsExpanded) { return; } this.targetExpandoButtonsOpen.setValue(1); this.setState({ buttonsExpanded: true }); }; hideButtons() { if ( ChatInputBar.mediaGalleryOpen(this.props) || !this.systemKeyboardShowing || !this.state.buttonsExpanded ) { return; } this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } immediatelyHideButtons() { this.expandoButtonsOpen.setValue(0); this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } openCamera = async () => { this.dismissKeyboard(); this.props.navigation.navigate({ name: CameraModalRouteName, params: { presentedFrom: this.props.route.key, threadID: this.props.threadInfo.id, }, }); }; showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery(this.props.threadInfo.id); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const unboundStyles = { cameraIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 10, paddingRight: 3, }, cameraRollIcon: { paddingBottom: Platform.OS === 'android' ? 8 : 7, paddingRight: 8, }, container: { backgroundColor: 'listBackground', }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'android' ? 12 : 10, }, expandoButtons: { alignSelf: 'flex-end', }, explanation: { color: 'listBackgroundSecondaryLabel', paddingBottom: 4, paddingTop: 1, textAlign: 'center', }, innerExpandoButtons: { alignItems: 'flex-end', alignSelf: 'flex-end', flexDirection: 'row', }, inputContainer: { flexDirection: 'row', }, joinButton: { backgroundColor: 'mintButton', borderRadius: 5, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, paddingBottom: 5, paddingTop: 3, }, joinButtonContainer: { flexDirection: 'row', height: 36, }, joinButtonText: { color: 'listBackground', fontSize: 20, textAlign: 'center', }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: Platform.OS === 'android' ? 4 : 3, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 5, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 10, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginVertical: 5, maxHeight: 250, paddingHorizontal: 10, paddingVertical: 5, }, }; const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); export default React.memo(function ConnectedChatInputBar( props: BaseProps, ) { const inputState = React.useContext(InputStateContext); const keyboardState = React.useContext(KeyboardContext); const navContext = React.useContext(NavContext); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const draftKey = draftKeyFromThreadID(props.threadInfo.id); const draft = useSelector((state) => state.drafts[draftKey] || ''); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const calendarQuery = useSelector((state) => nonThreadCalendarQuery({ redux: state, navContext, }), ); const nextLocalID = useSelector((state) => state.nextLocalID); const userInfos = useSelector((state) => state.userStore.userInfos); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); const callNewThread = useServerCall(newThread); const imagePastedCallback = React.useCallback( (imagePastedEvent) => { if (props.threadInfo.id !== imagePastedEvent['threadID']) { return; } const pastedImage: PhotoPaste = { step: 'photo_paste', dimensions: { height: imagePastedEvent.height, width: imagePastedEvent.width, }, filename: imagePastedEvent.fileName, uri: 'file://' + imagePastedEvent.filePath, selectTime: 0, sendTime: 0, retries: 0, }; props.navigation.navigate({ name: ImagePasteModalRouteName, params: { imagePasteStagingInfo: pastedImage, threadID: props.threadInfo.id, }, }); }, [props.navigation, props.threadInfo.id], ); React.useEffect(() => { const imagePasteListener = NativeAppEventEmitter.addListener( 'imagePasted', imagePastedCallback, ); return () => imagePasteListener.remove(); }, [imagePastedCallback]); return ( ); }); diff --git a/server/src/creators/thread-creator.js b/server/src/creators/thread-creator.js index aef03ab06..d0b6bbe86 100644 --- a/server/src/creators/thread-creator.js +++ b/server/src/creators/thread-creator.js @@ -1,482 +1,507 @@ // @flow import invariant from 'invariant'; import bots from 'lib/facts/bots'; import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils'; import { generatePendingThreadColor, generateRandomColor, } from 'lib/shared/thread-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import type { Shape } from 'lib/types/core'; import { messageTypes } from 'lib/types/message-types'; import { userRelationshipStatus } from 'lib/types/relationship-types'; import { type NewThreadRequest, type NewThreadResponse, threadTypes, threadPermissions, } from 'lib/types/thread-types'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import { dbQuery, SQL } from '../database/database'; import { fetchMessageInfoByID } from '../fetchers/message-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers'; import type { Viewer } from '../session/viewer'; import { changeRole, recalculateAllPermissions, commitMembershipChangeset, setJoinsToUnread, getRelationshipRowsForUsers, getParentThreadRelationshipRowsForNewUsers, } from '../updaters/thread-permission-updaters'; import createIDs from './id-creator'; import createMessages from './message-creator'; import { createInitialRolesForNewThread } from './role-creator'; import type { UpdatesForCurrentSession } from './update-creator'; const { squadbot } = bots; type CreateThreadOptions = Shape<{| +forceAddMembers: boolean, +updatesForCurrentSession: UpdatesForCurrentSession, + +silentlyFailMembers: boolean, |}>; // If forceAddMembers is set, we will allow the viewer to add random users who // they aren't friends with. We will only fail if the viewer is trying to add // somebody who they have blocked or has blocked them. On the other hand, if // forceAddMembers is not set, we will fail if the viewer tries to add somebody // who they aren't friends with and doesn't have a membership row with a // nonnegative role for the parent thread. async function createThread( viewer: Viewer, request: NewThreadRequest, options?: CreateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const updatesForCurrentSession = options?.updatesForCurrentSession ?? 'return'; + const silentlyFailMembers = options?.silentlyFailMembers ?? false; const threadType = request.type; const shouldCreateRelationships = forceAddMembers || threadType === threadTypes.PERSONAL; const parentThreadID = request.parentThreadID ? request.parentThreadID : null; - const initialMemberIDs = + const initialMemberIDsFromRequest = request.initialMemberIDs && request.initialMemberIDs.length > 0 ? request.initialMemberIDs : null; const ghostMemberIDs = request.ghostMemberIDs && request.ghostMemberIDs.length > 0 ? request.ghostMemberIDs : null; + const sourceMessageID = request.sourceMessageID + ? request.sourceMessageID + : null; + invariant( + threadType !== threadTypes.SIDEBAR || sourceMessageID, + 'sourceMessageID should be set for sidebar', + ); + if ( threadType !== threadTypes.CHAT_SECRET && threadType !== threadTypes.PERSONAL && threadType !== threadTypes.PRIVATE && !parentThreadID ) { throw new ServerError('invalid_parameters'); } if ( threadType === threadTypes.PERSONAL && (request.initialMemberIDs?.length !== 1 || parentThreadID) ) { throw new ServerError('invalid_parameters'); } const checkPromises = {}; if (parentThreadID) { checkPromises.parentThreadFetch = fetchThreadInfos( viewer, SQL`t.id = ${parentThreadID}`, ); checkPromises.hasParentPermission = checkThreadPermission( viewer, parentThreadID, threadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBTHREADS, ); } const memberIDs = []; - if (initialMemberIDs) { - memberIDs.push(...initialMemberIDs); + if (initialMemberIDsFromRequest) { + memberIDs.push(...initialMemberIDsFromRequest); } if (ghostMemberIDs) { memberIDs.push(...ghostMemberIDs); } - if (initialMemberIDs || ghostMemberIDs) { + if (initialMemberIDsFromRequest || ghostMemberIDs) { checkPromises.fetchMemberIDs = fetchKnownUserInfos(viewer, memberIDs); } + if (sourceMessageID) { + checkPromises.sourceMessage = fetchMessageInfoByID(viewer, sourceMessageID); + } + const { parentThreadFetch, hasParentPermission, fetchMemberIDs, + sourceMessage, } = await promiseAll(checkPromises); let parentThreadMembers; if (parentThreadID) { invariant(parentThreadFetch, 'parentThreadFetch should be set'); const parentThreadInfo = parentThreadFetch.threadInfos[parentThreadID]; if (!hasParentPermission) { throw new ServerError('invalid_credentials'); } parentThreadMembers = parentThreadInfo.members.map( (userInfo) => userInfo.id, ); } const viewerNeedsRelationshipsWith = []; + const silencedMemberIDs = new Set(); if (fetchMemberIDs) { - invariant(initialMemberIDs || ghostMemberIDs, 'should be set'); + invariant(initialMemberIDsFromRequest || ghostMemberIDs, 'should be set'); for (const memberID of memberIDs) { const member = fetchMemberIDs[memberID]; if ( !member && shouldCreateRelationships && (threadType !== threadTypes.SIDEBAR || parentThreadMembers?.includes(memberID)) ) { viewerNeedsRelationshipsWith.push(memberID); continue; + } else if (!member && silentlyFailMembers) { + silencedMemberIDs.add(memberID); + continue; } else if (!member) { throw new ServerError('invalid_credentials'); } + const { relationshipStatus } = member; + const memberRelationshipHasBlock = !!( + relationshipStatus && + relationshipBlockedInEitherDirection(relationshipStatus) + ); if ( relationshipStatus === userRelationshipStatus.FRIEND && threadType !== threadTypes.SIDEBAR ) { continue; - } else if ( - relationshipStatus && - relationshipBlockedInEitherDirection(relationshipStatus) - ) { + } else if (memberRelationshipHasBlock && silentlyFailMembers) { + silencedMemberIDs.add(memberID); + } else if (memberRelationshipHasBlock) { throw new ServerError('invalid_credentials'); } else if ( parentThreadMembers && parentThreadMembers.includes(memberID) ) { continue; + } else if (!shouldCreateRelationships && silentlyFailMembers) { + silencedMemberIDs.add(memberID); } else if (!shouldCreateRelationships) { throw new ServerError('invalid_credentials'); } } } + const filteredInitialMemberIDs: ?$ReadOnlyArray = initialMemberIDsFromRequest?.filter( + (id) => !silencedMemberIDs.has(id), + ); + const initialMemberIDs = + filteredInitialMemberIDs && filteredInitialMemberIDs.length > 0 + ? filteredInitialMemberIDs + : null; + const [id] = await createIDs('threads', 1); const newRoles = await createInitialRolesForNewThread(id, threadType); const name = request.name ? request.name : null; const description = request.description ? request.description : null; let color = request.color ? request.color.toLowerCase() : generateRandomColor(); if (threadType === threadTypes.PERSONAL) { color = generatePendingThreadColor( request.initialMemberIDs ?? [], viewer.id, ); } - const sourceMessageID = request.sourceMessageID - ? request.sourceMessageID - : null; - invariant( - threadType !== threadTypes.SIDEBAR || sourceMessageID, - 'sourceMessageID should be set for sidebar', - ); + const time = Date.now(); const row = [ id, threadType, name, description, viewer.userID, time, color, parentThreadID, newRoles.default.id, sourceMessageID, ]; if (threadType === threadTypes.PERSONAL) { const otherMemberID = initialMemberIDs?.[0]; invariant( otherMemberID, 'Other member id should be set for a PERSONAL thread', ); const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, default_role, source_message) SELECT ${row} WHERE NOT EXISTS ( SELECT * FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id AND m1.user = ${viewer.userID} INNER JOIN memberships m2 ON m2.thread = t.id AND m2.user = ${otherMemberID} WHERE t.type = ${threadTypes.PERSONAL} AND m1.role != -1 AND m2.role != -1 ) `; const [result] = await dbQuery(query); if (result.affectedRows === 0) { const personalThreadQuery = SQL` SELECT t.id FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id AND m1.user = ${viewer.userID} INNER JOIN memberships m2 ON m2.thread = t.id AND m2.user = ${otherMemberID} WHERE t.type = ${threadTypes.PERSONAL} AND m1.role != -1 AND m2.role != -1 `; const deleteRoles = SQL` DELETE FROM roles WHERE id IN (${newRoles.default.id}, ${newRoles.creator.id}) `; const deleteIDs = SQL` DELETE FROM ids WHERE id IN (${id}, ${newRoles.default.id}, ${newRoles.creator.id}) `; const [[personalThreadResult]] = await Promise.all([ dbQuery(personalThreadQuery), dbQuery(deleteRoles), dbQuery(deleteIDs), ]); invariant( personalThreadResult.length > 0, 'PERSONAL thread should exist', ); const personalThreadID = personalThreadResult[0].id.toString(); return { newThreadID: personalThreadID, updatesResult: { newUpdates: [], }, userInfos: {}, newMessageInfos: [], }; } } else { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, default_role, source_message) VALUES ${[row]} `; await dbQuery(query); } const [ creatorChangeset, initialMembersChangeset, ghostMembersChangeset, recalculatePermissionsChangeset, ] = await Promise.all([ changeRole(id, [viewer.userID], newRoles.creator.id), initialMemberIDs ? changeRole(id, initialMemberIDs, null) : undefined, ghostMemberIDs ? changeRole(id, ghostMemberIDs, -1) : undefined, recalculateAllPermissions(id, threadType), ]); if (!creatorChangeset) { throw new ServerError('unknown_error'); } const { membershipRows: creatorMembershipRows, relationshipRows: creatorRelationshipRows, } = creatorChangeset; const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = recalculatePermissionsChangeset; const membershipRows = [ ...creatorMembershipRows, ...recalculateMembershipRows, ]; const relationshipRows = [ ...creatorRelationshipRows, ...recalculateRelationshipRows, ]; if (initialMemberIDs || ghostMemberIDs) { if (!initialMembersChangeset && !ghostMembersChangeset) { throw new ServerError('unknown_error'); } relationshipRows.push( ...getRelationshipRowsForUsers( viewer.userID, viewerNeedsRelationshipsWith, ), ); const membersMembershipRows = []; const membersRelationshipRows = []; if (initialMembersChangeset) { const { membershipRows: initialMembersMembershipRows, relationshipRows: initialMembersRelationshipRows, } = initialMembersChangeset; membersMembershipRows.push(...initialMembersMembershipRows); membersRelationshipRows.push(...initialMembersRelationshipRows); } if (ghostMembersChangeset) { const { membershipRows: ghostMembersMembershipRows, relationshipRows: ghostMembersRelationshipRows, } = ghostMembersChangeset; membersMembershipRows.push(...ghostMembersMembershipRows); membersRelationshipRows.push(...ghostMembersRelationshipRows); } const memberAndCreatorIDs = [...memberIDs, viewer.userID]; const parentRelationshipRows = getParentThreadRelationshipRowsForNewUsers( id, recalculateMembershipRows, memberAndCreatorIDs, ); membershipRows.push(...membersMembershipRows); relationshipRows.push( ...membersRelationshipRows, ...parentRelationshipRows, ); } setJoinsToUnread(membershipRows, viewer.userID, id); const changeset = { membershipRows, relationshipRows }; const { threadInfos, viewerUpdates, userInfos, } = await commitMembershipChangeset(viewer, changeset, { updatesForCurrentSession, }); const initialMemberAndCreatorIDs = initialMemberIDs ? [...initialMemberIDs, viewer.userID] : [viewer.userID]; const messageDatas = []; if (threadType !== threadTypes.SIDEBAR) { messageDatas.push({ type: messageTypes.CREATE_THREAD, threadID: id, creatorID: viewer.userID, time, initialThreadState: { type: threadType, name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }); } else { invariant(parentThreadID, 'parentThreadID should be set for sidebar'); - invariant(sourceMessageID, 'sourceMessageID should be set for sidebar'); - const sourceMessage = await fetchMessageInfoByID(viewer, sourceMessageID); if (!sourceMessage || sourceMessage.type === messageTypes.SIDEBAR_SOURCE) { throw new ServerError('invalid_parameters'); } messageDatas.push( { type: messageTypes.CREATE_SIDEBAR, threadID: id, creatorID: viewer.userID, time, sourceMessageAuthorID: sourceMessage.creatorID, initialThreadState: { name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }, { type: messageTypes.SIDEBAR_SOURCE, threadID: id, creatorID: viewer.userID, time, sourceMessage, }, ); } if (parentThreadID && threadType !== threadTypes.SIDEBAR) { messageDatas.push({ type: messageTypes.CREATE_SUB_THREAD, threadID: parentThreadID, creatorID: viewer.userID, time, childThreadID: id, }); } const newMessageInfos = await createMessages( viewer, messageDatas, updatesForCurrentSession, ); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { newThreadID: id, updatesResult: { newUpdates: viewerUpdates, }, userInfos, newMessageInfos, }; } return { newThreadInfo: threadInfos[id], updatesResult: { newUpdates: viewerUpdates, }, userInfos, newMessageInfos, }; } function createPrivateThread( viewer: Viewer, username: string, ): Promise { return createThread( viewer, { type: threadTypes.PRIVATE, name: username, description: 'This is your private thread, ' + 'where you can set reminders and jot notes in private!', ghostMemberIDs: [squadbot.userID], }, { forceAddMembers: true, }, ); } export { createThread, createPrivateThread }; diff --git a/server/src/responders/thread-responders.js b/server/src/responders/thread-responders.js index f8fc3271a..c5430806c 100644 --- a/server/src/responders/thread-responders.js +++ b/server/src/responders/thread-responders.js @@ -1,182 +1,185 @@ // @flow import t from 'tcomb'; import { type ThreadDeletionRequest, type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type NewThreadRequest, type NewThreadResponse, type ServerThreadJoinRequest, type ThreadJoinResult, threadTypes, } from 'lib/types/thread-types'; import { values } from 'lib/utils/objects'; import { createThread } from '../creators/thread-creator'; import { deleteThread } from '../deleters/thread-deleters'; import type { Viewer } from '../session/viewer'; import { updateRole, removeMembers, leaveThread, updateThread, joinThread, } from '../updaters/thread-updaters'; import { validateInput, tShape, tNumEnum, tColor, tPassword, } from '../utils/validation-utils'; import { entryQueryInputValidator, verifyCalendarQueryThreadIDs, } from './entry-responders'; const threadDeletionRequestInputValidator = tShape({ threadID: t.String, accountPassword: tPassword, }); async function threadDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: ThreadDeletionRequest = input; await validateInput(viewer, threadDeletionRequestInputValidator, request); return await deleteThread(viewer, request); } const roleChangeRequestInputValidator = tShape({ threadID: t.String, memberIDs: t.list(t.String), role: t.refinement(t.String, (str) => { const int = parseInt(str, 10); return int == str && int > 0; }), }); async function roleUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: RoleChangeRequest = input; await validateInput(viewer, roleChangeRequestInputValidator, request); return await updateRole(viewer, request); } const removeMembersRequestInputValidator = tShape({ threadID: t.String, memberIDs: t.list(t.String), }); async function memberRemovalResponder( viewer: Viewer, input: any, ): Promise { const request: RemoveMembersRequest = input; await validateInput(viewer, removeMembersRequestInputValidator, request); return await removeMembers(viewer, request); } const leaveThreadRequestInputValidator = tShape({ threadID: t.String, }); async function threadLeaveResponder( viewer: Viewer, input: any, ): Promise { const request: LeaveThreadRequest = input; await validateInput(viewer, leaveThreadRequestInputValidator, request); return await leaveThread(viewer, request); } const updateThreadRequestInputValidator = tShape({ threadID: t.String, changes: tShape({ type: t.maybe(tNumEnum(values(threadTypes))), name: t.maybe(t.String), description: t.maybe(t.String), color: t.maybe(tColor), parentThreadID: t.maybe(t.String), newMemberIDs: t.maybe(t.list(t.String)), }), accountPassword: t.maybe(tPassword), }); async function threadUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: UpdateThreadRequest = input; await validateInput(viewer, updateThreadRequestInputValidator, request); return await updateThread(viewer, request); } const threadRequestValidationShape = { name: t.maybe(t.String), description: t.maybe(t.String), color: t.maybe(tColor), parentThreadID: t.maybe(t.String), initialMemberIDs: t.maybe(t.list(t.String)), }; const newThreadRequestInputValidator = t.union([ tShape({ type: tNumEnum([threadTypes.SIDEBAR]), sourceMessageID: t.String, ...threadRequestValidationShape, }), tShape({ type: tNumEnum([ threadTypes.CHAT_NESTED_OPEN, threadTypes.CHAT_SECRET, threadTypes.PERSONAL, ]), ...threadRequestValidationShape, }), ]); async function threadCreationResponder( viewer: Viewer, input: any, ): Promise { const request: NewThreadRequest = input; await validateInput(viewer, newThreadRequestInputValidator, request); - return await createThread(viewer, request); + + return await createThread(viewer, request, { + silentlyFailMembers: request.type === threadTypes.SIDEBAR, + }); } const joinThreadRequestInputValidator = tShape({ threadID: t.String, calendarQuery: t.maybe(entryQueryInputValidator), }); async function threadJoinResponder( viewer: Viewer, input: any, ): Promise { const request: ServerThreadJoinRequest = input; await validateInput(viewer, joinThreadRequestInputValidator, request); if (request.calendarQuery) { await verifyCalendarQueryThreadIDs(request.calendarQuery); } return await joinThread(viewer, request); } export { threadDeletionResponder, roleUpdateResponder, memberRemovalResponder, threadLeaveResponder, threadUpdateResponder, threadCreationResponder, threadJoinResponder, newThreadRequestInputValidator, };