diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 1fe4c9526..d25b2d9db 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,682 +1,684 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find'; import tinycolor from 'tinycolor2'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions'; import type { ChatThreadItem } from '../selectors/chat-selectors'; 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 { ThreadType } from '../types/thread-types'; import { type UpdateInfo, updateTypes } from '../types/update-types'; import type { GlobalAccountUserInfo, UserInfo, 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 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', ); 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('+'); } function createPendingThread( viewerID: string, threadType: ThreadType, members: $ReadOnlyArray, ) { const now = Date.now(); 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: null, description: null, color: generatePendingThreadColor(memberIDs, viewerID), creationTime: now, parentThreadID: null, members: [ { id: viewerID, role: role.id, permissions: membershipPermissions, }, ...members.map((member) => ({ id: member.id, role: role.id, permissions: membershipPermissions, })), ], roles: { [role.id]: role, }, currentUser: { role: role.id, permissions: membershipPermissions, subscription: { pushNotifs: false, home: false, }, unread: false, }, }; const userInfos = Object.fromEntries( members.map((member) => [member.id, member]), ); return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } function createPendingThreadItem( viewerID: string, user: GlobalAccountUserInfo, ): ChatThreadItem { const threadInfo = createPendingThread(viewerID, threadTypes.PERSONAL, [ user, ]); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo: { id: user.id, username: user.username, }, }; } 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, }); 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, + sourceMessageID: serverThreadInfo.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 { 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), + sourceMessageID: rawThreadInfo.sourceMessageID, }; } 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 === 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 isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } const userInfo = userInfos[member.id]; if (userInfo && userInfo.username) { searchTextArray.push(userInfo.username); } } return searchTextArray.join(' '); }; export { colorIsDark, generateRandomColor, generatePendingThreadColor, threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadIsGroupChat, threadIsPending, threadIsPersonalAndPending, getPendingThreadOtherUsers, getSingleOtherUser, getPendingThreadKey, createPendingThread, createPendingThreadItem, pendingThreadType, getCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, robotextName, threadInfoFromRawThreadInfo, rawThreadInfoFromThreadInfo, threadTypeDescriptions, usersInThreadInfo, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadSearchText, }; diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js index 94896c9c5..ded07035d 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,394 +1,399 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import type { Shape } from './core'; import type { CalendarQuery, CalendarResult, RawEntryInfo, } from './entry-types'; import type { RawMessageInfo, MessageTruncationStatuses, } from './message-types'; import type { ClientThreadInconsistencyReportCreationRequest } from './report-types'; import type { ThreadSubscription } from './subscription-types'; import type { UpdateInfo } from './update-types'; import type { UserInfo, AccountUserInfo } from './user-types'; export const threadTypes = Object.freeze({ //OPEN: 0, (DEPRECATED) //CLOSED: 1, (DEPRECATED) //SECRET: 2, (DEPRECATED) CHAT_NESTED_OPEN: 3, CHAT_SECRET: 4, SIDEBAR: 5, PERSONAL: 6, }); export type ThreadType = $Values; export function assertThreadType(threadType: number): ThreadType { invariant( threadType === 3 || threadType === 4 || threadType === 5 || threadType === 6, 'number is not ThreadType enum', ); return threadType; } export const threadPermissions = Object.freeze({ KNOW_OF: 'know_of', VISIBLE: 'visible', VOICED: 'voiced', EDIT_ENTRIES: 'edit_entries', EDIT_THREAD: 'edit_thread', DELETE_THREAD: 'delete_thread', CREATE_SUBTHREADS: 'create_subthreads', CREATE_SIDEBARS: 'create_sidebars', JOIN_THREAD: 'join_thread', EDIT_PERMISSIONS: 'edit_permissions', ADD_MEMBERS: 'add_members', REMOVE_MEMBERS: 'remove_members', CHANGE_ROLE: 'change_role', LEAVE_THREAD: 'leave_thread', }); export type ThreadPermission = $Values; export function assertThreadPermissions( ourThreadPermissions: string, ): ThreadPermission { invariant( ourThreadPermissions === 'know_of' || ourThreadPermissions === 'visible' || ourThreadPermissions === 'voiced' || ourThreadPermissions === 'edit_entries' || ourThreadPermissions === 'edit_thread' || ourThreadPermissions === 'delete_thread' || ourThreadPermissions === 'create_subthreads' || ourThreadPermissions === 'create_sidebars' || ourThreadPermissions === 'join_thread' || ourThreadPermissions === 'edit_permissions' || ourThreadPermissions === 'add_members' || ourThreadPermissions === 'remove_members' || ourThreadPermissions === 'change_role' || ourThreadPermissions === 'leave_thread', 'string is not threadPermissions enum', ); return ourThreadPermissions; } export const threadPermissionPrefixes = Object.freeze({ DESCENDANT: 'descendant_', CHILD: 'child_', OPEN: 'open_', OPEN_DESCENDANT: 'descendant_open_', }); export type ThreadPermissionInfo = | {| value: true, source: string |} | {| value: false, source: null |}; export type ThreadPermissionsBlob = { [permission: string]: ThreadPermissionInfo, }; export type ThreadRolePermissionsBlob = { [permission: string]: boolean }; export type ThreadPermissionsInfo = { [permission: ThreadPermission]: ThreadPermissionInfo, }; export const threadPermissionsInfoPropType = PropTypes.objectOf( PropTypes.oneOfType([ PropTypes.shape({ value: PropTypes.oneOf([true]), source: PropTypes.string.isRequired, }), PropTypes.shape({ value: PropTypes.oneOf([false]), source: PropTypes.oneOf([null]), }), ]), ); export type MemberInfo = {| id: string, role: ?string, permissions: ThreadPermissionsInfo, |}; export const memberInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, }); export type RelativeMemberInfo = {| ...MemberInfo, username: ?string, isViewer: boolean, |}; export const relativeMemberInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, username: PropTypes.string, isViewer: PropTypes.bool.isRequired, }); export type RoleInfo = {| id: string, name: string, permissions: ThreadRolePermissionsBlob, isDefault: boolean, |}; export type ThreadCurrentUserInfo = {| role: ?string, permissions: ThreadPermissionsInfo, subscription: ThreadSubscription, unread: ?boolean, |}; export type RawThreadInfo = {| id: string, type: ThreadType, name: ?string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, currentUser: ThreadCurrentUserInfo, + sourceMessageID?: ?string, |}; export type ThreadInfo = {| id: string, type: ThreadType, name: ?string, uiName: string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, currentUser: ThreadCurrentUserInfo, + sourceMessageID?: ?string, |}; export const threadTypePropType = PropTypes.oneOf([ threadTypes.CHAT_NESTED_OPEN, threadTypes.CHAT_SECRET, threadTypes.SIDEBAR, threadTypes.PERSONAL, ]); const rolePropType = PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, permissions: PropTypes.objectOf(PropTypes.bool).isRequired, isDefault: PropTypes.bool.isRequired, }); const currentUserPropType = PropTypes.shape({ role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, subscription: PropTypes.shape({ pushNotifs: PropTypes.bool.isRequired, home: PropTypes.bool.isRequired, }).isRequired, unread: PropTypes.bool, }); export const rawThreadInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, type: threadTypePropType.isRequired, name: PropTypes.string, description: PropTypes.string, color: PropTypes.string.isRequired, creationTime: PropTypes.number.isRequired, parentThreadID: PropTypes.string, members: PropTypes.arrayOf(memberInfoPropType).isRequired, roles: PropTypes.objectOf(rolePropType).isRequired, currentUser: currentUserPropType.isRequired, + sourceMessageID: PropTypes.string, }); export const threadInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, type: threadTypePropType.isRequired, name: PropTypes.string, uiName: PropTypes.string.isRequired, description: PropTypes.string, color: PropTypes.string.isRequired, creationTime: PropTypes.number.isRequired, parentThreadID: PropTypes.string, members: PropTypes.arrayOf(memberInfoPropType).isRequired, roles: PropTypes.objectOf(rolePropType).isRequired, currentUser: currentUserPropType.isRequired, + sourceMessageID: PropTypes.string, }); export type ServerMemberInfo = {| id: string, role: ?string, permissions: ThreadPermissionsInfo, subscription: ThreadSubscription, unread: ?boolean, |}; export type ServerThreadInfo = {| id: string, type: ThreadType, name: ?string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, + sourceMessageID?: ?string, |}; export type ThreadStore = {| threadInfos: { [id: string]: RawThreadInfo }, inconsistencyReports: $ReadOnlyArray, |}; export type ThreadDeletionRequest = {| threadID: string, accountPassword: string, |}; export type RemoveMembersRequest = {| threadID: string, memberIDs: $ReadOnlyArray, |}; export type RoleChangeRequest = {| threadID: string, memberIDs: $ReadOnlyArray, role: string, |}; export type ChangeThreadSettingsResult = {| threadInfo?: RawThreadInfo, threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, newMessageInfos: $ReadOnlyArray, |}; export type ChangeThreadSettingsPayload = {| threadID: string, updatesResult: { newUpdates: $ReadOnlyArray, }, newMessageInfos: $ReadOnlyArray, |}; export type LeaveThreadRequest = {| threadID: string, |}; export type LeaveThreadResult = {| threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, |}; export type LeaveThreadPayload = {| updatesResult: { newUpdates: $ReadOnlyArray, }, |}; export type ThreadChanges = Shape<{| type: ThreadType, name: string, description: string, color: string, parentThreadID: string, newMemberIDs: $ReadOnlyArray, |}>; export type UpdateThreadRequest = {| threadID: string, changes: ThreadChanges, |}; export type BaseNewThreadRequest = {| +name?: ?string, +description?: ?string, +color?: ?string, +parentThreadID?: ?string, +initialMemberIDs?: ?$ReadOnlyArray, |}; export type NewThreadRequest = | {| +type: 3 | 4 | 6, ...BaseNewThreadRequest, |} | {| +type: 5, +initialMessageID: string, ...BaseNewThreadRequest, |}; export type NewThreadResponse = {| +updatesResult: {| +newUpdates: $ReadOnlyArray, |}, +newMessageInfos: $ReadOnlyArray, +newThreadInfo?: RawThreadInfo, +userInfos: { [string]: AccountUserInfo }, +newThreadID?: string, |}; export type NewThreadResult = {| +updatesResult: {| +newUpdates: $ReadOnlyArray, |}, +newMessageInfos: $ReadOnlyArray, +userInfos: { [string]: AccountUserInfo }, +newThreadID: string, |}; export type ServerThreadJoinRequest = {| threadID: string, calendarQuery?: ?CalendarQuery, |}; export type ClientThreadJoinRequest = {| threadID: string, calendarQuery: CalendarQuery, |}; export type ThreadJoinResult = {| threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, rawMessageInfos: $ReadOnlyArray, truncationStatuses: MessageTruncationStatuses, userInfos: { [string]: AccountUserInfo }, rawEntryInfos?: ?$ReadOnlyArray, |}; export type ThreadJoinPayload = {| updatesResult: { newUpdates: $ReadOnlyArray, }, rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, userInfos: $ReadOnlyArray, calendarResult: CalendarResult, |}; export type SidebarInfo = {| +threadInfo: ThreadInfo, +lastUpdatedTime: number, +mostRecentNonLocalMessage: ?string, |}; // We can show a max of 3 sidebars inline underneath their parent in the chat // tab. If there are more, we show a button that opens a modal to see the rest export const maxReadSidebars = 3; // We can show a max of 5 sidebars inline underneath their parent // in the chat tab if every one of the displayed sidebars is unread export const maxUnreadSidebars = 5; diff --git a/server/src/fetchers/thread-fetchers.js b/server/src/fetchers/thread-fetchers.js index 94a624694..74da11b9f 100644 --- a/server/src/fetchers/thread-fetchers.js +++ b/server/src/fetchers/thread-fetchers.js @@ -1,146 +1,147 @@ // @flow import { getAllThreadPermissions } from 'lib/permissions/thread-permissions'; import { rawThreadInfoFromServerThreadInfo } from 'lib/shared/thread-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import type { RawThreadInfo, ServerThreadInfo } from 'lib/types/thread-types'; import { dbQuery, SQL, SQLStatement } from '../database/database'; import type { Viewer } from '../session/viewer'; type FetchServerThreadInfosResult = {| threadInfos: { [id: string]: ServerThreadInfo }, |}; async function fetchServerThreadInfos( condition?: SQLStatement, ): Promise { const whereClause = condition ? SQL`WHERE `.append(condition) : ''; const query = SQL` SELECT t.id, t.name, t.parent_thread_id, t.color, t.description, - t.type, t.creation_time, t.default_role, r.id AS role, + t.type, t.creation_time, t.default_role, t.source_message, r.id AS role, r.name AS role_name, r.permissions AS role_permissions, m.user, - m.permissions, m.subscription, + m.permissions, m.subscription, m.last_read_message < m.last_message AS unread FROM threads t LEFT JOIN ( SELECT thread, id, name, permissions FROM roles UNION SELECT id AS thread, 0 AS id, NULL AS name, NULL AS permissions FROM threads ) r ON r.thread = t.id LEFT JOIN memberships m ON m.role = r.id AND m.thread = t.id AND m.role >= 0 ` .append(whereClause) .append(SQL` ORDER BY m.user ASC`); const [result] = await dbQuery(query); const threadInfos = {}; for (let row of result) { const threadID = row.id.toString(); if (!threadInfos[threadID]) { threadInfos[threadID] = { id: threadID, type: row.type, name: row.name ? row.name : '', description: row.description ? row.description : '', color: row.color, creationTime: row.creation_time, parentThreadID: row.parent_thread_id ? row.parent_thread_id.toString() : null, members: [], roles: {}, + sourceMessageID: row.source_message, }; } const role = row.role.toString(); if (row.role && !threadInfos[threadID].roles[role]) { threadInfos[threadID].roles[role] = { id: role, name: row.role_name, permissions: JSON.parse(row.role_permissions), isDefault: role === row.default_role.toString(), }; } if (row.user) { const userID = row.user.toString(); const allPermissions = getAllThreadPermissions(row.permissions, threadID); threadInfos[threadID].members.push({ id: userID, permissions: allPermissions, role: row.role ? role : null, subscription: row.subscription, unread: row.role ? !!row.unread : null, }); } } return { threadInfos }; } export type FetchThreadInfosResult = {| threadInfos: { [id: string]: RawThreadInfo }, |}; async function fetchThreadInfos( viewer: Viewer, condition?: SQLStatement, ): Promise { const serverResult = await fetchServerThreadInfos(condition); return rawThreadInfosFromServerThreadInfos(viewer, serverResult); } function rawThreadInfosFromServerThreadInfos( viewer: Viewer, serverResult: FetchServerThreadInfosResult, ): FetchThreadInfosResult { const viewerID = viewer.id; const hasCodeVersionBelow70 = !hasMinCodeVersion(viewer.platformDetails, 70); const threadInfos = {}; for (let threadID in serverResult.threadInfos) { const serverThreadInfo = serverResult.threadInfos[threadID]; const threadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, viewerID, { includeVisibilityRules: hasCodeVersionBelow70, filterMemberList: hasCodeVersionBelow70, }, ); if (threadInfo) { threadInfos[threadID] = threadInfo; } } return { threadInfos }; } async function verifyThreadIDs( threadIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { if (threadIDs.length === 0) { return []; } const query = SQL`SELECT id FROM threads WHERE id IN (${threadIDs})`; const [result] = await dbQuery(query); const verified = []; for (let row of result) { verified.push(row.id.toString()); } return verified; } async function verifyThreadID(threadID: string): Promise { const result = await verifyThreadIDs([threadID]); return result.length !== 0; } export { fetchServerThreadInfos, fetchThreadInfos, rawThreadInfosFromServerThreadInfos, verifyThreadIDs, verifyThreadID, };