diff --git a/lib/selectors/thread-selectors.js b/lib/selectors/thread-selectors.js index 670587ac3..af8dc6f83 100644 --- a/lib/selectors/thread-selectors.js +++ b/lib/selectors/thread-selectors.js @@ -1,378 +1,380 @@ // @flow import invariant from 'invariant'; import _compact from 'lodash/fp/compact'; import _filter from 'lodash/fp/filter'; import _flow from 'lodash/fp/flow'; import _map from 'lodash/fp/map'; import _mapValues from 'lodash/fp/mapValues'; import _orderBy from 'lodash/fp/orderBy'; import _some from 'lodash/fp/some'; import _sortBy from 'lodash/fp/sortBy'; import _memoize from 'lodash/memoize'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { createEntryInfo } from '../shared/entry-utils'; import { getMostRecentNonLocalMessageID } from '../shared/message-utils'; import { threadInHomeChatList, threadInBackgroundChatList, threadInFilterList, threadInfoFromRawThreadInfo, threadHasPermission, threadInChatList, threadHasAdminRole, roleIsAdminRole, - getOtherMemberID, + getPendingPersonalThreadOtherUser, } from '../shared/thread-utils'; import type { EntryInfo } from '../types/entry-types'; import type { MessageStore, RawMessageInfo } from '../types/message-types'; import type { BaseAppState } from '../types/redux-types'; import { type ThreadInfo, type RawThreadInfo, type RelativeMemberInfo, threadPermissions, threadTypes, type SidebarInfo, } from '../types/thread-types'; import { dateString, dateFromString } from '../utils/date-utils'; import { values } from '../utils/objects'; import { filteredThreadIDsSelector, includeDeletedSelector, } from './calendar-filter-selectors'; import { relativeMemberInfoSelectorForMembersOfThread } from './user-selectors'; const _mapValuesWithKeys = _mapValues.convert({ cap: false }); type ThreadInfoSelectorType = ( state: BaseAppState<*>, ) => { [id: string]: ThreadInfo }; const threadInfoSelector: ThreadInfoSelectorType = createObjectSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, threadInfoFromRawThreadInfo, ); type ThreadSelectorType = ( possiblyPendingThread: ThreadInfo, ) => (state: BaseAppState<*>) => ?ThreadInfo; const possiblyPendingThreadInfoSelector: ThreadSelectorType = ( possiblyPendingThread: ThreadInfo, ) => createSelector( threadInfoSelector, (state: BaseAppState<*>) => state.currentUserInfo?.id, (threadInfos: { [id: string]: ThreadInfo }, currentUserID: ?string) => { const threadInfoFromState = threadInfos[possiblyPendingThread.id]; if (threadInfoFromState) { return threadInfoFromState; } if (possiblyPendingThread.type !== threadTypes.PERSONAL) { return undefined; } if (!currentUserID) { return possiblyPendingThread; } - const otherMemberID = getOtherMemberID(possiblyPendingThread); + const otherUserID = getPendingPersonalThreadOtherUser( + possiblyPendingThread, + ); - if (!otherMemberID) { + if (!otherUserID) { return possiblyPendingThread; } for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.type !== threadTypes.PERSONAL) { continue; } invariant( threadInfo.members.length === 2, 'Personal thread should have exactly two members', ); const members = new Set(threadInfo.members.map((member) => member.id)); - if (members.has(currentUserID) && members.has(otherMemberID)) { + if (members.has(currentUserID) && members.has(otherUserID)) { return threadInfo; } } return possiblyPendingThread; }, ); const canBeOnScreenThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( threadInfoSelector, (threadInfos: { [id: string]: ThreadInfo }) => { const result = []; for (let threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadInFilterList(threadInfo)) { continue; } result.push(threadInfo); } return result; }, ); const onScreenThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( filteredThreadIDsSelector, canBeOnScreenThreadInfos, (inputThreadIDs: ?Set, threadInfos: ThreadInfo[]) => { const threadIDs = inputThreadIDs; if (!threadIDs) { return threadInfos; } return threadInfos.filter((threadInfo) => threadIDs.has(threadInfo.id)); }, ); const onScreenEntryEditableThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( onScreenThreadInfos, (threadInfos: ThreadInfo[]) => threadInfos.filter((threadInfo) => threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES), ), ); const entryInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: EntryInfo } = createObjectSelector( (state: BaseAppState<*>) => state.entryStore.entryInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, createEntryInfo, ); // "current" means within startDate/endDate range, not deleted, and in // onScreenThreadInfos const currentDaysToEntries: ( state: BaseAppState<*>, ) => { [dayString: string]: EntryInfo[] } = createSelector( entryInfoSelector, (state: BaseAppState<*>) => state.entryStore.daysToEntries, (state: BaseAppState<*>) => state.navInfo.startDate, (state: BaseAppState<*>) => state.navInfo.endDate, onScreenThreadInfos, includeDeletedSelector, ( entryInfos: { [id: string]: EntryInfo }, daysToEntries: { [day: string]: string[] }, startDateString: string, endDateString: string, onScreen: ThreadInfo[], includeDeleted: boolean, ) => { const allDaysWithinRange = {}, startDate = dateFromString(startDateString), endDate = dateFromString(endDateString); for ( const curDate = startDate; curDate <= endDate; curDate.setDate(curDate.getDate() + 1) ) { allDaysWithinRange[dateString(curDate)] = []; } return _mapValuesWithKeys((_: string[], dayString: string) => _flow( _map((entryID: string) => entryInfos[entryID]), _compact, _filter( (entryInfo: EntryInfo) => (includeDeleted || !entryInfo.deleted) && _some(['id', entryInfo.threadID])(onScreen), ), _sortBy('creationTime'), )(daysToEntries[dayString] ? daysToEntries[dayString] : []), )(allDaysWithinRange); }, ); const childThreadInfos: ( state: BaseAppState<*>, ) => { [id: string]: $ReadOnlyArray } = createSelector( threadInfoSelector, (threadInfos: { [id: string]: ThreadInfo }) => { const result = {}; for (let id in threadInfos) { const threadInfo = threadInfos[id]; const parentThreadID = threadInfo.parentThreadID; if (parentThreadID === null || parentThreadID === undefined) { continue; } if (result[parentThreadID] === undefined) { result[parentThreadID] = []; } result[parentThreadID].push(threadInfo); } return result; }, ); function getMostRecentRawMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, ): ?RawMessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (let messageID of thread.messageIDs) { return messageStore.messages[messageID]; } return null; } const sidebarInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: $ReadOnlyArray } = createObjectSelector( childThreadInfos, (state: BaseAppState<*>) => state.messageStore, (childThreads: $ReadOnlyArray, messageStore: MessageStore) => { const sidebarInfos = []; for (const childThreadInfo of childThreads) { if ( !threadInChatList(childThreadInfo) || childThreadInfo.type !== threadTypes.SIDEBAR ) { continue; } const mostRecentRawMessageInfo = getMostRecentRawMessageInfo( childThreadInfo, messageStore, ); const lastUpdatedTime = mostRecentRawMessageInfo?.time ?? childThreadInfo.creationTime; const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( childThreadInfo, messageStore, ); sidebarInfos.push({ threadInfo: childThreadInfo, lastUpdatedTime, mostRecentNonLocalMessage, }); } return _orderBy('lastUpdatedTime')('desc')(sidebarInfos); }, ); const unreadCount: (state: BaseAppState<*>) => number = createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (threadInfos: { [id: string]: RawThreadInfo }): number => values(threadInfos).filter( (threadInfo) => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const unreadBackgroundCount: ( state: BaseAppState<*>, ) => number = createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (threadInfos: { [id: string]: RawThreadInfo }): number => values(threadInfos).filter( (threadInfo) => threadInBackgroundChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const baseOtherUsersButNoOtherAdmins = (threadID: string) => createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos[threadID], relativeMemberInfoSelectorForMembersOfThread(threadID), ( threadInfo: ?RawThreadInfo, members: $ReadOnlyArray, ): boolean => { if (!threadInfo) { return false; } if (!threadHasAdminRole(threadInfo)) { return false; } let otherUsersExist = false; let otherAdminsExist = false; for (let member of members) { const role = member.role; if (role === undefined || role === null || member.isViewer) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo?.roles[role])) { otherAdminsExist = true; break; } } return otherUsersExist && !otherAdminsExist; }, ); const otherUsersButNoOtherAdmins: ( threadID: string, ) => (state: BaseAppState<*>) => boolean = _memoize( baseOtherUsersButNoOtherAdmins, ); function mostRecentReadThread( messageStore: MessageStore, threadInfos: { [id: string]: RawThreadInfo }, ): ?string { let mostRecent = null; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.currentUser.unread) { continue; } const threadMessageInfo = messageStore.threads[threadID]; if (!threadMessageInfo) { continue; } const mostRecentMessageTime = threadMessageInfo.messageIDs.length === 0 ? threadInfo.creationTime : messageStore.messages[threadMessageInfo.messageIDs[0]].time; if (mostRecent && mostRecent.time >= mostRecentMessageTime) { continue; } const topLevelThreadID = threadInfo.type === threadTypes.SIDEBAR ? threadInfo.parentThreadID : threadID; mostRecent = { threadID: topLevelThreadID, time: mostRecentMessageTime }; } return mostRecent ? mostRecent.threadID : null; } const mostRecentReadThreadSelector: ( state: BaseAppState<*>, ) => ?string = createSelector( (state: BaseAppState<*>) => state.messageStore, (state: BaseAppState<*>) => state.threadStore.threadInfos, mostRecentReadThread, ); export { threadInfoSelector, onScreenThreadInfos, onScreenEntryEditableThreadInfos, entryInfoSelector, currentDaysToEntries, childThreadInfos, unreadCount, unreadBackgroundCount, otherUsersButNoOtherAdmins, mostRecentReadThread, mostRecentReadThreadSelector, possiblyPendingThreadInfoSelector, sidebarInfoSelector, }; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index b89b097ee..1ec96f9db 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,530 +1,554 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find'; import tinycolor from 'tinycolor2'; import { permissionLookup, getAllThreadPermissions, } from '../permissions/thread-permissions'; 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, threadTypes, threadPermissions, } from '../types/thread-types'; import { type UpdateInfo, updateTypes } from '../types/update-types'; import type { UserInfos } from '../types/user-types'; import { pluralize } from '../utils/text-utils'; 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 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 getOtherMemberID(threadInfo: ThreadInfo) { - if (!threadIsPersonalAndPending(threadInfo)) { - return undefined; - } - const otherMemberID = threadInfo.id.split('/')[1]; +function getPendingPersonalThreadOtherUser( + threadInfo: ThreadInfo | RawThreadInfo, +) { + invariant( + threadIsPersonalAndPending(threadInfo), + 'Thread should be personal and pending', + ); + + const otherUserID = threadInfo.id.split('/')[1]; invariant( - otherMemberID, + otherUserID, 'Pending thread should contain other member id in its id', ); - return otherMemberID; + return otherUserID; +} + +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]; } 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, }); 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 = { 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, }; 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 { return { 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), }; } function getCurrentUser( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(rawThreadInfo, viewerID, userInfos)) { return rawThreadInfo.currentUser; } return { ...rawThreadInfo.currentUser, permissions: { ...rawThreadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function threadIsWithBlockedUserOnly( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo) ) { return false; } - const otherUserInfos = threadInfo.members - .filter((threadMember) => threadMember.id !== viewerID) - .map((threadMember) => userInfos[threadMember.id]); - const otherUserRelationshipStatus = otherUserInfos[0]?.relationshipStatus; + const otherUserID = getSingleOtherUser(threadInfo, viewerID); + if (!otherUserID) { + return false; + } + const otherUserRelationshipStatus = + userInfos[otherUserID]?.relationshipStatus; if (checkOnlyViewerBlock) { return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ); } return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER || otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_VIEWER || otherUserRelationshipStatus === userRelationshipStatus.BOTH_BLOCKED ); } 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 { return { 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, }; } 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 userInfo = userInfos[member.id]; if (userInfo && userInfo.username) { searchTextArray.push(userInfo.username); } } return searchTextArray.join(' '); }; export { colorIsDark, generateRandomColor, threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadIsGroupChat, threadIsPending, threadIsPersonalAndPending, - getOtherMemberID, + getPendingPersonalThreadOtherUser, + getSingleOtherUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, robotextName, threadInfoFromRawThreadInfo, rawThreadInfoFromThreadInfo, threadTypeDescriptions, usersInThreadInfo, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadSearchText, }; diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index 899e0a929..8c94077ef 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,894 +1,890 @@ // @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, } 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 { - getOtherMemberID, threadHasPermission, viewerIsMember, threadIsPersonalAndPending, threadFrozenDueToViewerBlock, threadActualMembers, + getPendingPersonalThreadOtherUser, } 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 { messageTypes } from 'lib/types/message-types'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo, threadInfoPropType, threadPermissions, type ClientThreadJoinRequest, type ThreadJoinPayload, threadTypes, type NewThreadRequest, type NewThreadResult, } 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 } from '../navigation/route-names'; import { CameraModalRouteName } 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 = ( ); 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 (!threadIsPersonalAndPending(threadInfo)) { return threadInfo.id; } - const otherMemberID = getOtherMemberID(threadInfo); - invariant( - otherMemberID, - 'Pending thread should contain other member id in its id', - ); + const otherMemberID = getPendingPersonalThreadOtherUser(threadInfo); try { const resultPromise = this.props.newThread({ type: threadTypes.PERSONAL, initialMemberIDs: [otherMemberID], color: threadInfo.color, }); 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); return ( ); });