diff --git a/keyserver/src/fetchers/link-fetchers.js b/keyserver/src/fetchers/link-fetchers.js index 1e99a78d5..c9dd3d1fa 100644 --- a/keyserver/src/fetchers/link-fetchers.js +++ b/keyserver/src/fetchers/link-fetchers.js @@ -1,42 +1,58 @@ // @flow import type { InviteLinkVerificationRequest, InviteLinkVerificationResponse, } from 'lib/types/link-types.js'; import { dbQuery, SQL } from '../database/database.js'; import { Viewer } from '../session/viewer.js'; async function verifyInviteLink( viewer: Viewer, request: InviteLinkVerificationRequest, ): Promise { const query = SQL` SELECT c.name, i.community AS communityID, m.role FROM invite_links i INNER JOIN threads c ON c.id = i.community LEFT JOIN memberships m ON m.thread = i.community AND m.user = ${viewer.userID} WHERE i.name = ${request.secret} AND c.community IS NULL `; const [result] = await dbQuery(query); if (result.length === 0) { return { status: 'invalid', }; } const { name, communityID, role } = result[0]; const status = role > 0 ? 'already_joined' : 'valid'; return { status, community: { name, id: communityID.toString(), }, }; } -export { verifyInviteLink }; +async function checkIfInviteLinkIsValid( + secret: string, + communityID: string, +): Promise { + const query = SQL` + SELECT i.id + FROM invite_links i + INNER JOIN threads c ON c.id = i.community + WHERE i.name = ${secret} + AND i.community = ${communityID} + AND c.community IS NULL + `; + const [result] = await dbQuery(query); + return result.length === 1; +} + +export { verifyInviteLink, checkIfInviteLinkIsValid }; diff --git a/keyserver/src/responders/thread-responders.js b/keyserver/src/responders/thread-responders.js index c866c6972..7697d8832 100644 --- a/keyserver/src/responders/thread-responders.js +++ b/keyserver/src/responders/thread-responders.js @@ -1,225 +1,226 @@ // @flow import t from 'tcomb'; import type { TUnion, TInterface } from 'tcomb'; import { type ThreadDeletionRequest, type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerNewThreadRequest, type NewThreadResponse, type ServerThreadJoinRequest, type ThreadJoinResult, type ThreadFetchMediaResult, type ThreadFetchMediaRequest, type ToggleMessagePinRequest, type ToggleMessagePinResult, threadTypes, } from 'lib/types/thread-types.js'; import { updateUserAvatarRequestValidator } from 'lib/utils/avatar-utils.js'; import { values } from 'lib/utils/objects.js'; import { tShape, tNumEnum, tColor, tPassword, } from 'lib/utils/validation-utils.js'; import { entryQueryInputValidator, verifyCalendarQueryThreadIDs, } from './entry-responders.js'; import { createThread } from '../creators/thread-creator.js'; import { deleteThread } from '../deleters/thread-deleters.js'; import { fetchMediaForThread } from '../fetchers/upload-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { updateRole, removeMembers, leaveThread, updateThread, joinThread, toggleMessagePinForThread, } from '../updaters/thread-updaters.js'; import { validateInput } from '../utils/validation-utils.js'; const threadDeletionRequestInputValidator = tShape({ threadID: t.String, accountPassword: t.maybe(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 String(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)), avatar: t.maybe(updateUserAvatarRequestValidator), }), 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)), calendarQuery: t.maybe(entryQueryInputValidator), }; const newThreadRequestInputValidator: TUnion = t.union([ tShape({ type: tNumEnum([threadTypes.SIDEBAR]), sourceMessageID: t.String, ...threadRequestValidationShape, }), tShape({ type: tNumEnum([ threadTypes.COMMUNITY_OPEN_SUBTHREAD, threadTypes.COMMUNITY_SECRET_SUBTHREAD, threadTypes.PERSONAL, threadTypes.LOCAL, ]), ...threadRequestValidationShape, }), ]); async function threadCreationResponder( viewer: Viewer, input: any, ): Promise { const request: ServerNewThreadRequest = input; await validateInput(viewer, newThreadRequestInputValidator, request); return await createThread(viewer, request, { silentlyFailMembers: request.type === threadTypes.SIDEBAR, }); } const joinThreadRequestInputValidator = tShape({ threadID: t.String, calendarQuery: t.maybe(entryQueryInputValidator), + inviteLinkSecret: t.maybe(t.String), }); 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); } const threadFetchMediaRequestInputValidator = tShape({ threadID: t.String, limit: t.Number, offset: t.Number, }); async function threadFetchMediaResponder( viewer: Viewer, input: any, ): Promise { const request: ThreadFetchMediaRequest = input; await validateInput(viewer, threadFetchMediaRequestInputValidator, request); return await fetchMediaForThread(viewer, request); } const toggleMessagePinRequestInputValidator = tShape({ messageID: t.String, action: t.enums.of(['pin', 'unpin']), }); async function toggleMessagePinResponder( viewer: Viewer, input: any, ): Promise { const request: ToggleMessagePinRequest = input; await validateInput(viewer, toggleMessagePinRequestInputValidator, request); return await toggleMessagePinForThread(viewer, request); } export { threadDeletionResponder, roleUpdateResponder, memberRemovalResponder, threadLeaveResponder, threadUpdateResponder, threadCreationResponder, threadJoinResponder, threadFetchMediaResponder, newThreadRequestInputValidator, toggleMessagePinResponder, }; diff --git a/keyserver/src/updaters/link-updaters.js b/keyserver/src/updaters/link-updaters.js new file mode 100644 index 000000000..aa4a95a52 --- /dev/null +++ b/keyserver/src/updaters/link-updaters.js @@ -0,0 +1,14 @@ +// @flow + +import { dbQuery, SQL } from '../database/database.js'; + +async function reportLinkUsage(secret: string): Promise { + const query = SQL` + UPDATE invite_links + SET number_of_uses = number_of_uses + 1 + WHERE name = ${secret} + `; + await dbQuery(query); +} + +export { reportLinkUsage }; diff --git a/keyserver/src/updaters/thread-updaters.js b/keyserver/src/updaters/thread-updaters.js index 6bd11074e..2edd22807 100644 --- a/keyserver/src/updaters/thread-updaters.js +++ b/keyserver/src/updaters/thread-updaters.js @@ -1,1021 +1,1031 @@ // @flow import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors.js'; import { getPinnedContentFromMessage } from 'lib/shared/message-utils.js'; import { threadHasAdminRole, roleIsAdminRole, viewerIsMember, getThreadTypeParentRequirement, validChatNameRegex, } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { Shape } from 'lib/types/core.js'; import { messageTypes, defaultNumberPerThread, } from 'lib/types/message-types.js'; import { type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerThreadJoinRequest, type ThreadJoinResult, type ToggleMessagePinRequest, type ToggleMessagePinResult, threadPermissions, threadTypes, } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { firstLine } from 'lib/utils/string-utils.js'; +import { reportLinkUsage } from './link-updaters.js'; import { updateRoles } from './role-updaters.js'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, } from './thread-permission-updaters.js'; import createMessages from '../creators/message-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; +import { checkIfInviteLinkIsValid } from '../fetchers/link-fetchers.js'; import { fetchMessageInfos, fetchMessageInfoByID, } from '../fetchers/message-fetchers.js'; import { fetchThreadInfos, fetchServerThreadInfos, determineThreadAncestry, } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission, viewerIsMember as fetchViewerIsMember, checkThread, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers.js'; import { verifyUserIDs, verifyUserOrCookieIDs, } from '../fetchers/user-fetchers.js'; +import { handleAsyncPromise } from '../responders/handlers.js'; import type { Viewer } from '../session/viewer.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; async function updateRole( viewer: Viewer, request: RoleChangeRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT user, role FROM memberships WHERE user IN (${memberIDs}) AND thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonMemberUser = false; let numResults = 0; for (const row of result) { if (row.role <= 0) { nonMemberUser = true; break; } numResults++; } if (nonMemberUser || numResults < memberIDs.length) { throw new ServerError('invalid_parameters'); } const changeset = await changeRole(request.threadID, memberIDs, request.role); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.CHANGE_ROLE, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), userIDs: memberIDs, newRole: request.role, }; const newMessageInfos = await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function removeMembers( viewer: Viewer, request: RemoveMembersRequest, ): Promise { const viewerID = viewer.userID; if (request.memberIDs.includes(viewerID)) { throw new ServerError('invalid_parameters'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserOrCookieIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.REMOVE_MEMBERS, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT m.user, m.role, t.default_role FROM memberships m LEFT JOIN threads t ON t.id = m.thread WHERE m.user IN (${memberIDs}) AND m.thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonDefaultRoleUser = false; const actualMemberIDs = []; for (const row of result) { if (row.role <= 0) { continue; } actualMemberIDs.push(row.user.toString()); if (row.role !== row.default_role) { nonDefaultRoleUser = true; } } if (nonDefaultRoleUser) { const hasChangeRolePermission = await checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ); if (!hasChangeRolePermission) { throw new ServerError('invalid_credentials'); } } const changeset = await changeRole(request.threadID, actualMemberIDs, 0); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const newMessageInfos = await (async () => { if (actualMemberIDs.length === 0) { return []; } const messageData = { type: messageTypes.REMOVE_MEMBERS, threadID: request.threadID, creatorID: viewerID, time: Date.now(), removedUserIDs: actualMemberIDs, }; return await createMessages(viewer, [messageData]); })(); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function leaveThread( viewer: Viewer, request: LeaveThreadRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [fetchThreadResult, hasPermission] = await Promise.all([ fetchThreadInfos(viewer, SQL`t.id = ${request.threadID}`), checkThreadPermission( viewer, request.threadID, threadPermissions.LEAVE_THREAD, ), ]); const threadInfo = fetchThreadResult.threadInfos[request.threadID]; if (!viewerIsMember(threadInfo)) { if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: [] }, }; } const { threadInfos } = await fetchThreadInfos(viewer); return { threadInfos, updatesResult: { newUpdates: [], }, }; } if (!hasPermission) { throw new ServerError('invalid_parameters'); } const viewerID = viewer.userID; if (threadHasAdminRole(threadInfo)) { let otherUsersExist = false; let otherAdminsExist = false; for (const member of threadInfo.members) { const role = member.role; if (!role || member.id === viewerID) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo.roles[role])) { otherAdminsExist = true; break; } } if (otherUsersExist && !otherAdminsExist) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewerID], 0); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.LEAVE_THREAD, threadID: request.threadID, creatorID: viewerID, time: Date.now(), }; await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates } }; } return { threadInfos, updatesResult: { newUpdates: viewerUpdates, }, }; } type UpdateThreadOptions = Shape<{ +forceAddMembers: boolean, +forceUpdateRoot: boolean, +silenceMessages: boolean, +ignorePermissions: boolean, }>; async function updateThread( viewer: Viewer, request: UpdateThreadRequest, options?: UpdateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const forceUpdateRoot = options?.forceUpdateRoot ?? false; const silenceMessages = options?.silenceMessages ?? false; const ignorePermissions = (options?.ignorePermissions && viewer.isScriptViewer) ?? false; const validationPromises = {}; const changedFields = {}; const sqlUpdate = {}; const untrimmedName = request.changes.name; if (untrimmedName !== undefined && untrimmedName !== null) { const name = firstLine(untrimmedName); if (name.search(validChatNameRegex) === -1) { throw new ServerError('invalid_chat_name'); } changedFields.name = name; sqlUpdate.name = name ?? null; } const { description } = request.changes; if (description !== undefined && description !== null) { changedFields.description = description; sqlUpdate.description = description ?? null; } if (request.changes.color) { const color = request.changes.color.toLowerCase(); changedFields.color = color; sqlUpdate.color = color; } const { parentThreadID } = request.changes; if (parentThreadID !== undefined) { // TODO some sort of message when this changes sqlUpdate.parent_thread_id = parentThreadID; } const { avatar } = request.changes; if (avatar) { changedFields.avatar = avatar.type !== 'remove' ? JSON.stringify(avatar) : ''; sqlUpdate.avatar = avatar.type !== 'remove' ? JSON.stringify(avatar) : null; } const threadType = request.changes.type; if (threadType !== null && threadType !== undefined) { changedFields.type = threadType; sqlUpdate.type = threadType; } if ( !ignorePermissions && threadType !== null && threadType !== undefined && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && threadType !== threadTypes.COMMUNITY_SECRET_SUBTHREAD ) { throw new ServerError('invalid_parameters'); } const newMemberIDs = request.changes.newMemberIDs && request.changes.newMemberIDs.length > 0 ? [...new Set(request.changes.newMemberIDs)] : null; if ( Object.keys(sqlUpdate).length === 0 && !newMemberIDs && !forceUpdateRoot ) { throw new ServerError('invalid_parameters'); } validationPromises.serverThreadInfos = fetchServerThreadInfos( SQL`t.id = ${request.threadID}`, ); validationPromises.hasNecessaryPermissions = (async () => { if (ignorePermissions) { return; } const checks = []; if (sqlUpdate.name !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_NAME, }); } if (sqlUpdate.description !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_DESCRIPTION, }); } if (sqlUpdate.color !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_COLOR, }); } if (sqlUpdate.avatar !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_AVATAR, }); } if (parentThreadID !== undefined || sqlUpdate.type !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_PERMISSIONS, }); } if (newMemberIDs) { checks.push({ check: 'permission', permission: threadPermissions.ADD_MEMBERS, }); } const hasNecessaryPermissions = await checkThread( viewer, request.threadID, checks, ); if (!hasNecessaryPermissions) { throw new ServerError('invalid_credentials'); } })(); const { serverThreadInfos } = await promiseAll(validationPromises); const serverThreadInfo = serverThreadInfos.threadInfos[request.threadID]; if (!serverThreadInfo) { throw new ServerError('internal_error'); } // Threads with source message should be visible to everyone, but we can't // guarantee it for COMMUNITY_SECRET_SUBTHREAD threads so we forbid it for // now. In the future, if we want to support this, we would need to unlink the // source message. if ( threadType !== null && threadType !== undefined && threadType !== threadTypes.SIDEBAR && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && serverThreadInfo.sourceMessageID ) { throw new ServerError('invalid_parameters'); } // You can't change the parent thread of a current or former SIDEBAR if (parentThreadID !== undefined && serverThreadInfo.sourceMessageID) { throw new ServerError('invalid_parameters'); } const oldThreadType = serverThreadInfo.type; const oldParentThreadID = serverThreadInfo.parentThreadID; const oldContainingThreadID = serverThreadInfo.containingThreadID; const oldCommunity = serverThreadInfo.community; const oldDepth = serverThreadInfo.depth; const nextThreadType = threadType !== null && threadType !== undefined ? threadType : oldThreadType; let nextParentThreadID = parentThreadID !== undefined ? parentThreadID : oldParentThreadID; // Does the new thread type preclude a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'disabled' && nextParentThreadID !== null ) { nextParentThreadID = null; sqlUpdate.parent_thread_id = null; } // Does the new thread type require a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'required' && nextParentThreadID === null ) { throw new ServerError('no_parent_thread_specified'); } const determineThreadAncestryPromise = determineThreadAncestry( nextParentThreadID, nextThreadType, ); const confirmParentPermissionPromise = (async () => { if (ignorePermissions || !nextParentThreadID) { return; } if ( nextParentThreadID === oldParentThreadID && (nextThreadType === threadTypes.SIDEBAR) === (oldThreadType === threadTypes.SIDEBAR) ) { return; } const hasParentPermission = await checkThreadPermission( viewer, nextParentThreadID, nextThreadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBCHANNELS, ); if (!hasParentPermission) { throw new ServerError('invalid_parameters'); } })(); const rolesNeedUpdate = forceUpdateRoot || nextThreadType !== oldThreadType; const validateNewMembersPromise = (async () => { if (!newMemberIDs || ignorePermissions) { return; } const defaultRolePermissionsPromise = (async () => { let rolePermissions; if (!rolesNeedUpdate) { const rolePermissionsQuery = SQL` SELECT r.permissions FROM threads t LEFT JOIN roles r ON r.id = t.default_role WHERE t.id = ${request.threadID} `; const [result] = await dbQuery(rolePermissionsQuery); if (result.length > 0) { rolePermissions = JSON.parse(result[0].permissions); } } if (!rolePermissions) { rolePermissions = getRolePermissionBlobs(nextThreadType).Members; } return rolePermissions; })(); const [defaultRolePermissions, nextThreadAncestry] = await Promise.all([ defaultRolePermissionsPromise, determineThreadAncestryPromise, ]); const { newMemberIDs: validatedIDs } = await validateCandidateMembers( viewer, { newMemberIDs }, { threadType: nextThreadType, parentThreadID: nextParentThreadID, containingThreadID: nextThreadAncestry.containingThreadID, defaultRolePermissions, }, { requireRelationship: !forceAddMembers }, ); if ( validatedIDs && Number(validatedIDs?.length) < Number(newMemberIDs?.length) ) { throw new ServerError('invalid_credentials'); } })(); const { nextThreadAncestry } = await promiseAll({ nextThreadAncestry: determineThreadAncestryPromise, confirmParentPermissionPromise, validateNewMembersPromise, }); if (nextThreadAncestry.containingThreadID !== oldContainingThreadID) { sqlUpdate.containing_thread_id = nextThreadAncestry.containingThreadID; } if (nextThreadAncestry.community !== oldCommunity) { if (!ignorePermissions) { throw new ServerError('invalid_parameters'); } sqlUpdate.community = nextThreadAncestry.community; } if (nextThreadAncestry.depth !== oldDepth) { sqlUpdate.depth = nextThreadAncestry.depth; } const updateQueryPromise = (async () => { if (Object.keys(sqlUpdate).length === 0) { return; } const { avatar: avatarUpdate, ...nonAvatarUpdates } = sqlUpdate; const updatePromises = []; if (Object.keys(nonAvatarUpdates).length > 0) { const nonAvatarUpdateQuery = SQL` UPDATE threads SET ${nonAvatarUpdates} WHERE id = ${request.threadID} `; updatePromises.push(dbQuery(nonAvatarUpdateQuery)); } if (avatarUpdate !== undefined) { const avatarUploadID = avatar && avatar.type === 'image' ? avatar.uploadID : null; const avatarUpdateQuery = SQL` START TRANSACTION; UPDATE uploads SET container = NULL WHERE container = ${request.threadID} AND ( ${avatarUploadID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL ) ); UPDATE uploads SET container = ${request.threadID} WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL; UPDATE threads SET avatar = ${avatarUpdate} WHERE id = ${request.threadID} AND ( ${avatarUploadID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container = ${request.threadID} AND thread IS NULL ) ); COMMIT; `; updatePromises.push( dbQuery(avatarUpdateQuery, { multipleStatements: true }), ); } await Promise.all(updatePromises); })(); const updateRolesPromise = (async () => { if (rolesNeedUpdate) { await updateRoles(viewer, request.threadID, nextThreadType); } })(); const intermediatePromises = {}; intermediatePromises.updateQuery = updateQueryPromise; intermediatePromises.updateRoles = updateRolesPromise; if (newMemberIDs) { intermediatePromises.addMembersChangeset = (async () => { await Promise.all([updateQueryPromise, updateRolesPromise]); return await changeRole(request.threadID, newMemberIDs, null, { setNewMembersToUnread: true, }); })(); } const threadRootChanged = rolesNeedUpdate || nextParentThreadID !== oldParentThreadID; if (threadRootChanged) { intermediatePromises.recalculatePermissionsChangeset = (async () => { await Promise.all([updateQueryPromise, updateRolesPromise]); return await recalculateThreadPermissions(request.threadID); })(); } const { addMembersChangeset, recalculatePermissionsChangeset } = await promiseAll(intermediatePromises); const membershipRows = []; const relationshipChangeset = new RelationshipChangeset(); if (recalculatePermissionsChangeset) { const { membershipRows: recalculateMembershipRows, relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); relationshipChangeset.addAll(recalculateRelationshipChangeset); } let addedMemberIDs; if (addMembersChangeset) { const { membershipRows: addMembersMembershipRows, relationshipChangeset: addMembersRelationshipChangeset, } = addMembersChangeset; addedMemberIDs = addMembersMembershipRows .filter( row => row.operation === 'save' && row.threadID === request.threadID && Number(row.role) > 0, ) .map(row => row.userID); membershipRows.push(...addMembersMembershipRows); relationshipChangeset.addAll(addMembersRelationshipChangeset); } const changeset = { membershipRows, relationshipChangeset }; const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, { // This forces an update for this thread, // regardless of whether any membership rows are changed changedThreadIDs: Object.keys(sqlUpdate).length > 0 ? new Set([request.threadID]) : new Set(), }, ); let newMessageInfos = []; if (!silenceMessages) { const time = Date.now(); const messageDatas = []; for (const fieldName in changedFields) { const newValue = changedFields[fieldName]; messageDatas.push({ type: messageTypes.CHANGE_SETTINGS, threadID: request.threadID, creatorID: viewer.userID, time, field: fieldName, value: newValue, }); } if (addedMemberIDs && addedMemberIDs.length > 0) { messageDatas.push({ type: messageTypes.ADD_MEMBERS, threadID: request.threadID, creatorID: viewer.userID, time, addedUserIDs: addedMemberIDs, }); } newMessageInfos = await createMessages(viewer, messageDatas); } if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function joinThread( viewer: Viewer, request: ServerThreadJoinRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } + const permissionCheck = request.inviteLinkSecret + ? checkIfInviteLinkIsValid(request.inviteLinkSecret, request.threadID) + : checkThreadPermission( + viewer, + request.threadID, + threadPermissions.JOIN_THREAD, + ); const [isMember, hasPermission] = await Promise.all([ fetchViewerIsMember(viewer, request.threadID), - checkThreadPermission( - viewer, - request.threadID, - threadPermissions.JOIN_THREAD, - ), + permissionCheck, ]); if (!hasPermission) { throw new ServerError('invalid_parameters'); } // TODO: determine code version const hasCodeVersionBelow87 = !hasMinCodeVersion(viewer.platformDetails, 87); const hasCodeVersionBelow62 = !hasMinCodeVersion(viewer.platformDetails, 62); const { calendarQuery } = request; if (isMember) { const response: ThreadJoinResult = { rawMessageInfos: [], truncationStatuses: {}, userInfos: {}, updatesResult: { newUpdates: [], }, }; if (calendarQuery && hasCodeVersionBelow87) { response.rawEntryInfos = []; } if (hasCodeVersionBelow62) { response.threadInfos = {}; } return response; } if (calendarQuery) { const threadFilterIDs = filteredThreadIDs(calendarQuery.filters); if ( !threadFilterIDs || threadFilterIDs.size !== 1 || threadFilterIDs.values().next().value !== request.threadID ) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewer.userID], null); const membershipResult = await commitMembershipChangeset(viewer, changeset, { calendarQuery, }); + if (request.inviteLinkSecret) { + handleAsyncPromise(reportLinkUsage(request.inviteLinkSecret)); + } + const messageData = { type: messageTypes.JOIN_THREAD, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), }; const newMessages = await createMessages(viewer, [messageData]); const messageSelectionCriteria = { threadCursors: { [request.threadID]: false }, }; if (!hasCodeVersionBelow87) { return { rawMessageInfos: newMessages, truncationStatuses: {}, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; } const [fetchMessagesResult, fetchEntriesResult] = await Promise.all([ fetchMessageInfos(viewer, messageSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, ]); const rawEntryInfos = fetchEntriesResult && fetchEntriesResult.rawEntryInfos; const response: ThreadJoinResult = { rawMessageInfos: fetchMessagesResult.rawMessageInfos, truncationStatuses: fetchMessagesResult.truncationStatuses, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; if (hasCodeVersionBelow62) { response.threadInfos = membershipResult.threadInfos; } if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } async function updateThreadMembers(viewer: Viewer) { const { threadInfos } = await fetchThreadInfos( viewer, SQL`t.parent_thread_id IS NOT NULL `, ); const updateDatas = []; const time = Date.now(); for (const threadID in threadInfos) { updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID: viewer.id, time, threadID: threadID, targetSession: viewer.session, }); } await createUpdates(updateDatas); } async function toggleMessagePinForThread( viewer: Viewer, request: ToggleMessagePinRequest, ): Promise { const { messageID, action } = request; const targetMessage = await fetchMessageInfoByID(viewer, messageID); if (!targetMessage) { throw new ServerError('invalid_parameters'); } const { threadID } = targetMessage; const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.MANAGE_PINS, ); if (!hasPermission) { throw new ServerError('invalid_credentials'); } const pinnedValue = action === 'pin' ? 1 : 0; const pinTimeValue = action === 'pin' ? Date.now() : null; const togglePinQuery = SQL` UPDATE messages SET pinned = ${pinnedValue}, pin_time = ${pinTimeValue} WHERE id = ${messageID} AND thread = ${threadID} `; const messageData = { type: messageTypes.TOGGLE_PIN, threadID, targetMessageID: messageID, action, pinnedContent: getPinnedContentFromMessage(targetMessage), creatorID: viewer.userID, time: Date.now(), }; let updateThreadQuery; if (action === 'pin') { updateThreadQuery = SQL` UPDATE threads SET pinned_count = pinned_count + 1 WHERE id = ${threadID} `; } else { updateThreadQuery = SQL` UPDATE threads SET pinned_count = pinned_count - 1 WHERE id = ${threadID} `; } const [{ threadInfos: serverThreadInfos }] = await Promise.all([ fetchServerThreadInfos(SQL`t.id = ${threadID}`), dbQuery(togglePinQuery), dbQuery(updateThreadQuery), ]); const newMessageInfos = await createMessages(viewer, [messageData]); const time = Date.now(); const updates = []; for (const member of serverThreadInfos[threadID].members) { updates.push({ userID: member.id, time, threadID, type: updateTypes.UPDATE_THREAD, }); } await createUpdates(updates); return { newMessageInfos, threadID, }; } export { updateRole, removeMembers, leaveThread, updateThread, joinThread, updateThreadMembers, toggleMessagePinForThread, }; diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js index 6fdd0acfa..8db32b407 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,514 +1,516 @@ // @flow import invariant from 'invariant'; import type { AvatarDBContent, ClientAvatar, UpdateUserAvatarRequest, } from './avatar-types.js'; import type { Shape } from './core.js'; import type { CalendarQuery, RawEntryInfo } from './entry-types.js'; import type { Media } from './media-types.js'; import type { RawMessageInfo, MessageTruncationStatuses, } from './message-types.js'; import type { ThreadSubscription } from './subscription-types.js'; import type { ServerUpdateInfo, ClientUpdateInfo } from './update-types.js'; import type { UserInfo, UserInfos } from './user-types.js'; import type { ThreadEntity } from '../utils/entity-text.js'; export const threadTypes = Object.freeze({ //OPEN: 0, (DEPRECATED) //CLOSED: 1, (DEPRECATED) //SECRET: 2, (DEPRECATED) // has parent, not top-level (appears under parent in inbox), and visible to // all members of parent SIDEBAR: 5, // canonical thread for each pair of users. represents the friendship PERSONAL: 6, // canonical thread for each single user PRIVATE: 7, // local "thick" thread (outside of community). no parent, can only have // sidebar children. currently a proxy for COMMUNITY_SECRET_SUBTHREAD until we // launch actual E2E LOCAL: 4, // aka "org". no parent, top-level, has admin COMMUNITY_ROOT: 8, // like COMMUNITY_ROOT, but members aren't voiced COMMUNITY_ANNOUNCEMENT_ROOT: 9, // an open subthread. has parent, top-level (not sidebar), and visible to all // members of parent. root ancestor is a COMMUNITY_ROOT COMMUNITY_OPEN_SUBTHREAD: 3, // like COMMUNITY_SECRET_SUBTHREAD, but members aren't voiced COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD: 10, // a secret subthread. optional parent, top-level (not sidebar), visible only // to its members. root ancestor is a COMMUNITY_ROOT COMMUNITY_SECRET_SUBTHREAD: 4, // like COMMUNITY_SECRET_SUBTHREAD, but members aren't voiced COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD: 11, // like COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD, but you can't leave GENESIS: 12, }); export type ThreadType = $Values; export function assertThreadType(threadType: number): ThreadType { invariant( threadType === 3 || threadType === 4 || threadType === 5 || threadType === 6 || threadType === 7 || threadType === 8 || threadType === 9 || threadType === 10 || threadType === 11 || threadType === 12, 'number is not ThreadType enum', ); return threadType; } export const communityThreadTypes: $ReadOnlyArray = Object.freeze([ threadTypes.COMMUNITY_ROOT, threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT, threadTypes.GENESIS, ]); export const communitySubthreads: $ReadOnlyArray = Object.freeze([ threadTypes.COMMUNITY_OPEN_SUBTHREAD, threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD, threadTypes.COMMUNITY_SECRET_SUBTHREAD, threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD, ]); export function threadTypeIsCommunityRoot(threadType: ThreadType): boolean { return communityThreadTypes.includes(threadType); } export const threadPermissions = Object.freeze({ KNOW_OF: 'know_of', MEMBERSHIP_DEPRECATED: 'membership', VISIBLE: 'visible', VOICED: 'voiced', EDIT_ENTRIES: 'edit_entries', EDIT_THREAD_NAME: 'edit_thread', EDIT_THREAD_DESCRIPTION: 'edit_thread_description', EDIT_THREAD_COLOR: 'edit_thread_color', DELETE_THREAD: 'delete_thread', CREATE_SUBCHANNELS: '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', REACT_TO_MESSAGE: 'react_to_message', EDIT_MESSAGE: 'edit_message', EDIT_THREAD_AVATAR: 'edit_thread_avatar', MANAGE_PINS: 'manage_pins', }); export type ThreadPermission = $Values; export function assertThreadPermissions( ourThreadPermissions: string, ): ThreadPermission { invariant( ourThreadPermissions === 'know_of' || ourThreadPermissions === 'membership' || ourThreadPermissions === 'visible' || ourThreadPermissions === 'voiced' || ourThreadPermissions === 'edit_entries' || ourThreadPermissions === 'edit_thread' || ourThreadPermissions === 'edit_thread_description' || ourThreadPermissions === 'edit_thread_color' || 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' || ourThreadPermissions === 'react_to_message' || ourThreadPermissions === 'edit_message' || ourThreadPermissions === 'edit_thread_avatar' || ourThreadPermissions === 'manage_pins', 'string is not threadPermissions enum', ); return ourThreadPermissions; } export const threadPermissionPropagationPrefixes = Object.freeze({ DESCENDANT: 'descendant_', CHILD: 'child_', }); export type ThreadPermissionPropagationPrefix = $Values< typeof threadPermissionPropagationPrefixes, >; export const threadPermissionFilterPrefixes = Object.freeze({ // includes only SIDEBAR, COMMUNITY_OPEN_SUBTHREAD, // COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD OPEN: 'open_', // excludes only SIDEBAR TOP_LEVEL: 'toplevel_', // includes only COMMUNITY_OPEN_SUBTHREAD, // COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD OPEN_TOP_LEVEL: 'opentoplevel_', }); export type ThreadPermissionFilterPrefix = $Values< typeof threadPermissionFilterPrefixes, >; 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 type MemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +isSender: boolean, }; export type RelativeMemberInfo = { ...MemberInfo, +username: ?string, +isViewer: boolean, }; 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, +avatar?: ?ClientAvatar, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: RoleInfo }, +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type ThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +uiName: string | ThreadEntity, +avatar?: ?ClientAvatar, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: RoleInfo }, +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type ResolvedThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +uiName: string, +avatar?: ?ClientAvatar, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: RoleInfo }, +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type ServerMemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, +isSender: boolean, }; export type ServerThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +avatar?: AvatarDBContent, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +depth: number, +members: $ReadOnlyArray, +roles: { +[id: string]: RoleInfo }, +sourceMessageID?: string, +repliesCount: number, +pinnedCount: number, }; export type ThreadStore = { +threadInfos: { +[id: string]: RawThreadInfo }, }; export type RemoveThreadOperation = { +type: 'remove', +payload: { +ids: $ReadOnlyArray }, }; export type RemoveAllThreadsOperation = { +type: 'remove_all', }; export type ReplaceThreadOperation = { +type: 'replace', +payload: { +id: string, +threadInfo: RawThreadInfo }, }; export type ThreadStoreOperation = | RemoveThreadOperation | RemoveAllThreadsOperation | ReplaceThreadOperation; export type ClientDBThreadInfo = { +id: string, +type: number, +name: ?string, +avatar?: ?string, +description: ?string, +color: string, +creationTime: string, +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: string, +roles: string, +currentUser: string, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type ClientDBReplaceThreadOperation = { +type: 'replace', +payload: ClientDBThreadInfo, }; export type ClientDBThreadStoreOperation = | RemoveThreadOperation | RemoveAllThreadsOperation | ClientDBReplaceThreadOperation; 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, +avatar: UpdateUserAvatarRequest, }>; export type UpdateThreadRequest = { +threadID: string, +changes: ThreadChanges, }; export type BaseNewThreadRequest = { +id?: ?string, +name?: ?string, +description?: ?string, +color?: ?string, +parentThreadID?: ?string, +initialMemberIDs?: ?$ReadOnlyArray, +ghostMemberIDs?: ?$ReadOnlyArray, }; type NewThreadRequest = | { +type: 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12, ...BaseNewThreadRequest, } | { +type: 5, +sourceMessageID: string, ...BaseNewThreadRequest, }; export type ClientNewThreadRequest = { ...NewThreadRequest, +calendarQuery: CalendarQuery, }; export type ServerNewThreadRequest = { ...NewThreadRequest, +calendarQuery?: ?CalendarQuery, }; export type NewThreadResponse = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, +newThreadInfo?: RawThreadInfo, +userInfos: UserInfos, +newThreadID?: string, }; export type NewThreadResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, +userInfos: UserInfos, +newThreadID: string, }; export type ServerThreadJoinRequest = { +threadID: string, +calendarQuery?: ?CalendarQuery, + +inviteLinkSecret?: string, }; export type ClientThreadJoinRequest = { +threadID: string, +calendarQuery: CalendarQuery, + +inviteLinkSecret?: string, }; export type ThreadJoinResult = { threadInfos?: { +[id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, rawMessageInfos: $ReadOnlyArray, truncationStatuses: MessageTruncationStatuses, userInfos: UserInfos, rawEntryInfos?: ?$ReadOnlyArray, }; export type ThreadJoinPayload = { +updatesResult: { newUpdates: $ReadOnlyArray, }, +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: $ReadOnlyArray, }; export type ThreadFetchMediaResult = { +media: $ReadOnlyArray, }; export type ThreadFetchMediaRequest = { +threadID: string, +limit: number, +offset: number, }; export type SidebarInfo = { +threadInfo: ThreadInfo, +lastUpdatedTime: number, +mostRecentNonLocalMessage: ?string, }; export type ToggleMessagePinRequest = { +messageID: string, +action: 'pin' | 'unpin', }; export type ToggleMessagePinResult = { +newMessageInfos: $ReadOnlyArray, +threadID: 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; export type ThreadStoreThreadInfos = { +[id: string]: RawThreadInfo };