diff --git a/keyserver/src/creators/account-creator.js b/keyserver/src/creators/account-creator.js index 802e23cfe..6b427b340 100644 --- a/keyserver/src/creators/account-creator.js +++ b/keyserver/src/creators/account-creator.js @@ -1,376 +1,376 @@ // @flow import { getRustAPI } from 'rust-node-addon'; import bcrypt from 'twin-bcrypt'; import ashoat from 'lib/facts/ashoat.js'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import { policyTypes } from 'lib/facts/policies.js'; import { validUsernameRegex } from 'lib/shared/account-utils.js'; import type { RegisterResponse, RegisterRequest, } from 'lib/types/account-types.js'; import type { UserDetail, ReservedUsernameMessage, SignedIdentityKeysBlob, } from 'lib/types/crypto-types.js'; import type { PlatformDetails, DeviceTokenUpdateRequest, } from 'lib/types/device-types.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { SIWESocialProof } from 'lib/types/siwe-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { ignorePromiseRejections } from 'lib/utils/promises.js'; import { reservedUsernamesSet } from 'lib/utils/reserved-users.js'; import { isValidEthereumAddress } from 'lib/utils/siwe-utils.js'; import createIDs from './id-creator.js'; import createMessages from './message-creator.js'; import { createAndPersistOlmSession } from './olm-session-creator.js'; import { createThread, createPrivateThread, privateThreadDescription, } from './thread-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { deleteCookie } from '../deleters/cookie-deleters.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchLoggedInUserInfo, fetchKnownUserInfos, } from '../fetchers/user-fetchers.js'; import { verifyCalendarQueryThreadIDs } from '../responders/entry-responders.js'; import { searchForUser } from '../search/users.js'; import { createNewUserCookie, setNewSession } from '../session/cookies.js'; import { createScriptViewer } from '../session/scripts.js'; import type { Viewer } from '../session/viewer.js'; import { fetchOlmAccount } from '../updaters/olm-account-updater.js'; import { updateThread } from '../updaters/thread-updaters.js'; import { viewerAcknowledgmentUpdater } from '../updaters/viewer-acknowledgment-updater.js'; const { commbot } = bots; const ashoatMessages = [ 'welcome to Comm!', 'as you inevitably discover bugs, have feature requests, or design ' + 'suggestions, feel free to message them to me in the app.', ]; const privateMessages = [privateThreadDescription]; async function createAccount( viewer: Viewer, request: RegisterRequest, ): Promise { if (request.password.trim() === '') { throw new ServerError('empty_password'); } if (request.username.search(validUsernameRegex) === -1) { throw new ServerError('invalid_username'); } const promises = [searchForUser(request.username)]; const { calendarQuery, signedIdentityKeysBlob, initialNotificationsEncryptedMessage, } = request; if (calendarQuery) { promises.push(verifyCalendarQueryThreadIDs(calendarQuery)); } const [existingUser] = await Promise.all(promises); if ( reservedUsernamesSet.has(request.username.toLowerCase()) || isValidEthereumAddress(request.username.toLowerCase()) ) { throw new ServerError('username_reserved'); } if (existingUser) { throw new ServerError('username_taken'); } const hash = bcrypt.hashSync(request.password); const time = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [id] = await createIDs('users', 1); const newUserRow = [id, request.username, hash, time]; const newUserQuery = SQL` INSERT INTO users(id, username, hash, creation_time) VALUES ${[newUserRow]} `; const [userViewerData] = await Promise.all([ createNewUserCookie(id, { platformDetails: request.platformDetails, deviceToken, signedIdentityKeysBlob, }), deleteCookie(viewer.cookieID), dbQuery(newUserQuery), ]); viewer.setNewCookie(userViewerData); if (calendarQuery) { await setNewSession(viewer, calendarQuery, 0); } const olmSessionPromise = (async () => { if (userViewerData.cookieID && initialNotificationsEncryptedMessage) { await createAndPersistOlmSession( initialNotificationsEncryptedMessage, 'notifications', userViewerData.cookieID, ); } })(); await Promise.all([ updateThread( createScriptViewer(ashoat.id), { - threadID: genesis.id, + threadID: genesis().id, changes: { newMemberIDs: [id] }, }, { forceAddMembers: true, silenceMessages: true, ignorePermissions: true }, ), viewerAcknowledgmentUpdater(viewer, policyTypes.tosAndPrivacyPolicy), olmSessionPromise, ]); const [privateThreadResult, ashoatThreadResult] = await Promise.all([ createPrivateThread(viewer), createThread( viewer, { type: threadTypes.PERSONAL, initialMemberIDs: [ashoat.id], }, { forceAddMembers: true }, ), ]); const ashoatThreadID = ashoatThreadResult.newThreadID; const privateThreadID = privateThreadResult.newThreadID; let messageTime = Date.now(); const ashoatMessageDatas = ashoatMessages.map(message => ({ type: messageTypes.TEXT, threadID: ashoatThreadID, creatorID: ashoat.id, time: messageTime++, text: message, })); const privateMessageDatas = privateMessages.map(message => ({ type: messageTypes.TEXT, threadID: privateThreadID, creatorID: commbot.userID, time: messageTime++, text: message, })); const messageDatas = [...ashoatMessageDatas, ...privateMessageDatas]; const [messageInfos, threadsResult, userInfos, currentUserInfo] = await Promise.all([ createMessages(viewer, messageDatas), fetchThreadInfos(viewer), fetchKnownUserInfos(viewer), fetchLoggedInUserInfo(viewer), ]); const rawMessageInfos = [ ...ashoatThreadResult.newMessageInfos, ...privateThreadResult.newMessageInfos, ...messageInfos, ]; ignorePromiseRejections( createAndSendReservedUsernameMessage([ { username: request.username, userID: id }, ]), ); return { id, rawMessageInfos, currentUserInfo, cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: values(userInfos), }, }; } export type ProcessSIWEAccountCreationRequest = { +address: string, +calendarQuery: CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, +socialProof: SIWESocialProof, +signedIdentityKeysBlob?: ?SignedIdentityKeysBlob, }; // Note: `processSIWEAccountCreation(...)` assumes that the validity of // `ProcessSIWEAccountCreationRequest` was checked at call site. async function processSIWEAccountCreation( viewer: Viewer, request: ProcessSIWEAccountCreationRequest, ): Promise { const { calendarQuery, signedIdentityKeysBlob } = request; await verifyCalendarQueryThreadIDs(calendarQuery); const time = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [id] = await createIDs('users', 1); const newUserRow = [id, request.address, request.address, time]; const newUserQuery = SQL` INSERT INTO users(id, username, ethereum_address, creation_time) VALUES ${[newUserRow]} `; const [userViewerData] = await Promise.all([ createNewUserCookie(id, { platformDetails: request.platformDetails, deviceToken, socialProof: request.socialProof, signedIdentityKeysBlob, }), deleteCookie(viewer.cookieID), dbQuery(newUserQuery), ]); viewer.setNewCookie(userViewerData); await setNewSession(viewer, calendarQuery, 0); await processAccountCreationCommon(viewer); ignorePromiseRejections( createAndSendReservedUsernameMessage([ { username: request.address, userID: id }, ]), ); return id; } export type ProcessOLMAccountCreationRequest = { +userID: string, +username: string, +walletAddress?: ?string, +calendarQuery: CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, +signedIdentityKeysBlob: SignedIdentityKeysBlob, }; // Note: `processOLMAccountCreation(...)` assumes that the validity of // `ProcessOLMAccountCreationRequest` was checked at call site. async function processOLMAccountCreation( viewer: Viewer, request: ProcessOLMAccountCreationRequest, ): Promise { const { calendarQuery, signedIdentityKeysBlob } = request; await verifyCalendarQueryThreadIDs(calendarQuery); const time = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const newUserRow = [ request.userID, request.username, request.walletAddress, time, ]; const newUserQuery = SQL` INSERT INTO users(id, username, ethereum_address, creation_time) VALUES ${[newUserRow]} `; const [userViewerData] = await Promise.all([ createNewUserCookie(request.userID, { platformDetails: request.platformDetails, deviceToken, signedIdentityKeysBlob, }), deleteCookie(viewer.cookieID), dbQuery(newUserQuery), ]); viewer.setNewCookie(userViewerData); await setNewSession(viewer, calendarQuery, 0); await processAccountCreationCommon(viewer); } async function processAccountCreationCommon(viewer: Viewer) { await Promise.all([ updateThread( createScriptViewer(ashoat.id), { - threadID: genesis.id, + threadID: genesis().id, changes: { newMemberIDs: [viewer.userID] }, }, { forceAddMembers: true, silenceMessages: true, ignorePermissions: true }, ), viewerAcknowledgmentUpdater(viewer, policyTypes.tosAndPrivacyPolicy), ]); const [privateThreadResult, ashoatThreadResult] = await Promise.all([ createPrivateThread(viewer), createThread( viewer, { type: threadTypes.PERSONAL, initialMemberIDs: [ashoat.id], }, { forceAddMembers: true }, ), ]); const ashoatThreadID = ashoatThreadResult.newThreadID; const privateThreadID = privateThreadResult.newThreadID; let messageTime = Date.now(); const ashoatMessageDatas = ashoatMessages.map(message => ({ type: messageTypes.TEXT, threadID: ashoatThreadID, creatorID: ashoat.id, time: messageTime++, text: message, })); const privateMessageDatas = privateMessages.map(message => ({ type: messageTypes.TEXT, threadID: privateThreadID, creatorID: commbot.userID, time: messageTime++, text: message, })); const messageDatas = [...ashoatMessageDatas, ...privateMessageDatas]; await Promise.all([createMessages(viewer, messageDatas)]); } async function createAndSendReservedUsernameMessage( payload: $ReadOnlyArray, ) { const issuedAt = new Date().toISOString(); const reservedUsernameMessage: ReservedUsernameMessage = { statement: 'Add the following usernames to reserved list', payload, issuedAt, }; const stringifiedMessage = JSON.stringify(reservedUsernameMessage); const [rustAPI, accountInfo] = await Promise.all([ getRustAPI(), fetchOlmAccount('content'), ]); const signature = accountInfo.account.sign(stringifiedMessage); await rustAPI.addReservedUsernames(stringifiedMessage, signature); } export { createAccount, processSIWEAccountCreation, processOLMAccountCreation }; diff --git a/keyserver/src/creators/thread-creator.js b/keyserver/src/creators/thread-creator.js index 887b98af0..d79bc788d 100644 --- a/keyserver/src/creators/thread-creator.js +++ b/keyserver/src/creators/thread-creator.js @@ -1,509 +1,509 @@ // @flow import invariant from 'invariant'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; import { generatePendingThreadColor, generateRandomColor, } from 'lib/shared/color-utils.js'; import { isInvalidSidebarSource } from 'lib/shared/message-utils.js'; import { getThreadTypeParentRequirement } from 'lib/shared/thread-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { RawMessageInfo, MessageData } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes, threadTypeIsCommunityRoot, } from 'lib/types/thread-types-enum.js'; import { type ServerNewThreadRequest, type NewThreadResponse, } from 'lib/types/thread-types.js'; import type { ServerUpdateInfo } from 'lib/types/update-types.js'; import type { UserInfos } from 'lib/types/user-types.js'; import { pushAll } from 'lib/utils/array.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { firstLine } from 'lib/utils/string-utils.js'; import createIDs from './id-creator.js'; import createMessages from './message-creator.js'; import { createInitialRolesForNewThread } from './role-creator.js'; import type { UpdatesForCurrentSession } from './update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchLatestEditMessageContentByID, fetchMessageInfoByID, } from '../fetchers/message-fetchers.js'; import { determineThreadAncestry, personalThreadQuery, } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, getChangesetCommitResultForExistingThread, type MembershipChangeset, } from '../updaters/thread-permission-updaters.js'; import { joinThread } from '../updaters/thread-updaters.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; const { commbot } = bots; const privateThreadDescription: string = 'This is your private chat, ' + 'where you can set reminders and jot notes in private!'; type CreateThreadOptions = Partial<{ +forceAddMembers: boolean, +updatesForCurrentSession: UpdatesForCurrentSession, +silentlyFailMembers: boolean, }>; // If forceAddMembers is set, we will allow the viewer to add random users who // they aren't friends with. We will only fail if the viewer is trying to add // somebody who they have blocked or has blocked them. On the other hand, if // forceAddMembers is not set, we will fail if the viewer tries to add somebody // who they aren't friends with and doesn't have a membership row with a // nonnegative role for the parent thread. async function createThread( viewer: Viewer, request: ServerNewThreadRequest, options?: CreateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const updatesForCurrentSession = options?.updatesForCurrentSession ?? 'return'; const silentlyFailMembers = options?.silentlyFailMembers ?? false; const threadType = request.type; const shouldCreateRelationships = forceAddMembers || threadType === threadTypes.PERSONAL; let parentThreadID = request.parentThreadID ? request.parentThreadID : null; const initialMemberIDsFromRequest = request.initialMemberIDs && request.initialMemberIDs.length > 0 ? [...new Set(request.initialMemberIDs)] : null; const ghostMemberIDsFromRequest = request.ghostMemberIDs && request.ghostMemberIDs.length > 0 ? [...new Set(request.ghostMemberIDs)] : null; const sourceMessageID = request.sourceMessageID ? request.sourceMessageID : null; invariant( threadType !== threadTypes.SIDEBAR || sourceMessageID, 'sourceMessageID should be set for sidebar', ); const parentRequirement = getThreadTypeParentRequirement(threadType); if ( (parentRequirement === 'required' && !parentThreadID) || (parentRequirement === 'disabled' && parentThreadID) ) { throw new ServerError('invalid_parameters'); } if ( threadType === threadTypes.PERSONAL && request.initialMemberIDs?.length !== 1 ) { throw new ServerError('invalid_parameters'); } const requestParentThreadID = parentThreadID; const confirmParentPermissionPromise = (async () => { if (!requestParentThreadID) { return; } const hasParentPermission = await checkThreadPermission( viewer, requestParentThreadID, threadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBCHANNELS, ); if (!hasParentPermission) { throw new ServerError('invalid_credentials'); } })(); // This is a temporary hack until we release actual E2E-encrypted local // conversations. For now we are hosting all root threads on Ashoat's // keyserver, so we set them to the have the Genesis community as their // parent thread. if (!parentThreadID && !threadTypeIsCommunityRoot(threadType)) { - parentThreadID = genesis.id; + parentThreadID = genesis().id; } const determineThreadAncestryPromise = determineThreadAncestry( parentThreadID, threadType, ); const validateMembersPromise = (async () => { const threadAncestry = await determineThreadAncestryPromise; const defaultRolePermissions = getRolePermissionBlobs(threadType).Members; const { initialMemberIDs, ghostMemberIDs } = await validateCandidateMembers( viewer, { initialMemberIDs: initialMemberIDsFromRequest, ghostMemberIDs: ghostMemberIDsFromRequest, }, { threadType, parentThreadID, containingThreadID: threadAncestry.containingThreadID, defaultRolePermissions, }, { requireRelationship: !shouldCreateRelationships }, ); if ( !silentlyFailMembers && (Number(initialMemberIDs?.length) < Number(initialMemberIDsFromRequest?.length) || Number(ghostMemberIDs?.length) < Number(ghostMemberIDsFromRequest?.length)) ) { throw new ServerError('invalid_credentials'); } return { initialMemberIDs, ghostMemberIDs }; })(); const sourceMessagePromise: Promise = sourceMessageID ? fetchMessageInfoByID(viewer, sourceMessageID) : Promise.resolve(undefined); const { sourceMessage, threadAncestry, validateMembers: { initialMemberIDs, ghostMemberIDs }, } = await promiseAll({ sourceMessage: sourceMessagePromise, threadAncestry: determineThreadAncestryPromise, validateMembers: validateMembersPromise, confirmParentPermission: confirmParentPermissionPromise, }); if (sourceMessage && isInvalidSidebarSource(sourceMessage)) { throw new ServerError('invalid_parameters'); } let { id } = request; if (id === null || id === undefined) { const ids = await createIDs('threads', 1); id = ids[0]; } const newRoles = await createInitialRolesForNewThread(id, threadType); const name = request.name ? firstLine(request.name) : null; const description = request.description ? request.description : null; let color = request.color ? request.color.toLowerCase() : generateRandomColor(); if (threadType === threadTypes.PERSONAL) { color = generatePendingThreadColor([ ...(request.initialMemberIDs ?? []), viewer.id, ]); } const time = Date.now(); const row = [ id, threadType, name, description, viewer.userID, time, color, parentThreadID, threadAncestry.containingThreadID, threadAncestry.community, threadAncestry.depth, sourceMessageID, ]; let existingThreadQuery = null; if (threadType === threadTypes.PERSONAL) { const otherMemberID = initialMemberIDs?.[0]; invariant( otherMemberID, 'Other member id should be set for a PERSONAL thread', ); existingThreadQuery = personalThreadQuery(viewer.userID, otherMemberID); } else if (sourceMessageID) { existingThreadQuery = SQL` SELECT t.id FROM threads t WHERE t.source_message = ${sourceMessageID} `; } if (existingThreadQuery) { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, source_message) SELECT ${row} WHERE NOT EXISTS (`; query.append(existingThreadQuery).append(SQL`)`); const [result] = await dbQuery(query); if (result.affectedRows === 0) { const deleteRoles = SQL` DELETE FROM roles WHERE id IN (${newRoles.default.id}, ${newRoles.creator.id}) `; const deleteIDs = SQL` DELETE FROM ids WHERE id IN (${id}, ${newRoles.default.id}, ${newRoles.creator.id}) `; const [[existingThreadResult]] = await Promise.all([ dbQuery(existingThreadQuery), dbQuery(deleteRoles), dbQuery(deleteIDs), ]); invariant(existingThreadResult.length > 0, 'thread should exist'); const existingThreadID = existingThreadResult[0].id.toString(); invariant(request.calendarQuery, 'calendar query should exist'); const calendarQuery = { ...request.calendarQuery, filters: [ ...request.calendarQuery.filters, { type: 'threads', threadIDs: [existingThreadID] }, ], }; let joinUpdateInfos: $ReadOnlyArray = []; let userInfos: UserInfos = {}; let newMessageInfos: $ReadOnlyArray = []; if (threadType !== threadTypes.PERSONAL) { const joinThreadResult = await joinThread(viewer, { threadID: existingThreadID, calendarQuery, }); joinUpdateInfos = joinThreadResult.updatesResult.newUpdates; userInfos = joinThreadResult.userInfos; newMessageInfos = joinThreadResult.rawMessageInfos; } const { viewerUpdates: newUpdates, userInfos: changesetUserInfos } = await getChangesetCommitResultForExistingThread( viewer, existingThreadID, joinUpdateInfos, { calendarQuery, updatesForCurrentSession }, ); userInfos = { ...userInfos, ...changesetUserInfos }; return { newThreadID: existingThreadID, updatesResult: { newUpdates, }, userInfos, newMessageInfos, }; } } else { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, source_message) VALUES ${[row]} `; await dbQuery(query); } const initialMemberPromise: Promise = initialMemberIDs ? changeRole(id, initialMemberIDs, null, { setNewMembersToUnread: true }) : Promise.resolve(undefined); const ghostMemberPromise: Promise = ghostMemberIDs ? changeRole(id, ghostMemberIDs, -1) : Promise.resolve(undefined); const [ creatorChangeset, initialMembersChangeset, ghostMembersChangeset, recalculatePermissionsChangeset, ] = await Promise.all([ changeRole(id, [viewer.userID], newRoles.creator.id), initialMemberPromise, ghostMemberPromise, recalculateThreadPermissions(id), ]); const { membershipRows: creatorMembershipRows, relationshipChangeset: creatorRelationshipChangeset, } = creatorChangeset; const { membershipRows: recalculateMembershipRows, relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; const membershipRows = [ ...creatorMembershipRows, ...recalculateMembershipRows, ]; const relationshipChangeset = new RelationshipChangeset(); relationshipChangeset.addAll(creatorRelationshipChangeset); relationshipChangeset.addAll(recalculateRelationshipChangeset); if (initialMembersChangeset) { const { membershipRows: initialMembersMembershipRows, relationshipChangeset: initialMembersRelationshipChangeset, } = initialMembersChangeset; pushAll(membershipRows, initialMembersMembershipRows); relationshipChangeset.addAll(initialMembersRelationshipChangeset); } if (ghostMembersChangeset) { const { membershipRows: ghostMembersMembershipRows, relationshipChangeset: ghostMembersRelationshipChangeset, } = ghostMembersChangeset; pushAll(membershipRows, ghostMembersMembershipRows); relationshipChangeset.addAll(ghostMembersRelationshipChangeset); } const changeset = { membershipRows, relationshipChangeset }; const { viewerUpdates, userInfos } = await commitMembershipChangeset( viewer, changeset, { updatesForCurrentSession, }, ); const initialMemberAndCreatorIDs = initialMemberIDs ? [...initialMemberIDs, viewer.userID] : [viewer.userID]; const messageDatas: Array = []; if (threadType !== threadTypes.SIDEBAR) { messageDatas.push({ type: messageTypes.CREATE_THREAD, threadID: id, creatorID: viewer.userID, time, initialThreadState: { type: threadType, name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }); } else { invariant(parentThreadID, 'parentThreadID should be set for sidebar'); if (!sourceMessage) { throw new ServerError('invalid_parameters'); } invariant( sourceMessage.type !== messageTypes.REACTION && sourceMessage.type !== messageTypes.EDIT_MESSAGE && sourceMessage.type !== messageTypes.SIDEBAR_SOURCE && sourceMessage.type !== messageTypes.TOGGLE_PIN, 'Invalid sidebar source type', ); let editedSourceMessage = sourceMessage; if (sourceMessageID && sourceMessage.type === messageTypes.TEXT) { const editMessageContent = await fetchLatestEditMessageContentByID(sourceMessageID); if (editMessageContent) { editedSourceMessage = { ...sourceMessage, text: editMessageContent.text, }; } } messageDatas.push( { type: messageTypes.SIDEBAR_SOURCE, threadID: id, creatorID: viewer.userID, time, sourceMessage: editedSourceMessage, }, { type: messageTypes.CREATE_SIDEBAR, threadID: id, creatorID: viewer.userID, time, sourceMessageAuthorID: sourceMessage.creatorID, initialThreadState: { name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }, ); } if ( parentThreadID && threadType !== threadTypes.SIDEBAR && - (parentThreadID !== genesis.id || + (parentThreadID !== genesis().id || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD) ) { messageDatas.push({ type: messageTypes.CREATE_SUB_THREAD, threadID: parentThreadID, creatorID: viewer.userID, time, childThreadID: id, }); } const newMessageInfos = await createMessages( viewer, messageDatas, updatesForCurrentSession, ); return { newThreadID: id, updatesResult: { newUpdates: viewerUpdates, }, userInfos, newMessageInfos, }; } function createPrivateThread(viewer: Viewer): Promise { return createThread( viewer, { type: threadTypes.PRIVATE, description: privateThreadDescription, ghostMemberIDs: [commbot.userID], }, { forceAddMembers: true, }, ); } export { createThread, createPrivateThread, privateThreadDescription }; diff --git a/keyserver/src/database/migration-config.js b/keyserver/src/database/migration-config.js index 68ccd9a63..916c5eafe 100644 --- a/keyserver/src/database/migration-config.js +++ b/keyserver/src/database/migration-config.js @@ -1,858 +1,860 @@ // @flow import fs from 'fs'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import { policyTypes } from 'lib/facts/policies.js'; import { specialRoles } from 'lib/permissions/special-roles.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { permissionsToRemoveInMigration } from 'lib/utils/migration-utils.js'; import { dbQuery, SQL } from '../database/database.js'; import { processMessagesInDBForSearch } from '../database/search-utils.js'; import { deleteThread } from '../deleters/thread-deleters.js'; import { createScriptViewer } from '../session/scripts.js'; import { fetchOlmAccount } from '../updaters/olm-account-updater.js'; import { updateRolesAndPermissionsForAllThreads } from '../updaters/thread-permission-updaters.js'; import { updateThread } from '../updaters/thread-updaters.js'; import { ensureUserCredentials } from '../user/checks.js'; import { createPickledOlmAccount, publishPrekeysToIdentity, } from '../utils/olm-utils.js'; import { synchronizeInviteLinksWithBlobs } from '../utils/synchronize-invite-links-with-blobs.js'; const botViewer = createScriptViewer(bots.commbot.userID); const migrations: $ReadOnlyMap Promise> = new Map([ [ 0, async () => { await makeSureBaseRoutePathExists('facts/commapp_url.json'); await makeSureBaseRoutePathExists('facts/squadcal_url.json'); }, ], [ 1, async () => { try { await fs.promises.unlink('facts/url.json'); } catch {} }, ], [ 2, async () => { await fixBaseRoutePathForLocalhost('facts/commapp_url.json'); await fixBaseRoutePathForLocalhost('facts/squadcal_url.json'); }, ], [3, updateRolesAndPermissionsForAllThreads], [ 4, async () => { await dbQuery(SQL`ALTER TABLE uploads ADD INDEX container (container)`); }, ], [ 5, async () => { await dbQuery(SQL` ALTER TABLE cookies ADD device_id varchar(255) DEFAULT NULL, ADD public_key varchar(255) DEFAULT NULL, ADD social_proof varchar(255) DEFAULT NULL; `); }, ], [ 7, async () => { await dbQuery( SQL` ALTER TABLE users DROP COLUMN IF EXISTS public_key, MODIFY hash char(60) COLLATE utf8mb4_bin DEFAULT NULL; ALTER TABLE sessions DROP COLUMN IF EXISTS public_key; `, { multipleStatements: true }, ); }, ], [ 8, async () => { await dbQuery( SQL` ALTER TABLE users ADD COLUMN IF NOT EXISTS ethereum_address char(42) DEFAULT NULL; `, ); }, ], [ 9, async () => { await dbQuery( SQL` ALTER TABLE messages ADD COLUMN IF NOT EXISTS target_message bigint(20) DEFAULT NULL; ALTER TABLE messages ADD INDEX target_message (target_message); `, { multipleStatements: true }, ); }, ], [ 10, async () => { await dbQuery(SQL` CREATE TABLE IF NOT EXISTS policy_acknowledgments ( user bigint(20) NOT NULL, policy varchar(255) NOT NULL, date bigint(20) NOT NULL, confirmed tinyint(1) UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (user, policy) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; `); }, ], [ 11, async () => { const time = Date.now(); await dbQuery(SQL` INSERT IGNORE INTO policy_acknowledgments (policy, user, date, confirmed) SELECT ${policyTypes.tosAndPrivacyPolicy}, id, ${time}, 1 FROM users `); }, ], [ 12, async () => { await dbQuery(SQL` CREATE TABLE IF NOT EXISTS siwe_nonces ( nonce char(17) NOT NULL, creation_time bigint(20) NOT NULL, PRIMARY KEY (nonce) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; `); }, ], [ 13, async () => { await Promise.all([ writeSquadCalRoute('facts/squadcal_url.json'), moveToNonApacheConfig('facts/commapp_url.json', '/comm/'), moveToNonApacheConfig('facts/landing_url.json', '/commlanding/'), ]); }, ], [ 14, async () => { await dbQuery(SQL` ALTER TABLE cookies MODIFY COLUMN social_proof mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL; `); }, ], [ 15, async () => { await dbQuery( SQL` ALTER TABLE uploads ADD COLUMN IF NOT EXISTS thread bigint(20) DEFAULT NULL, ADD INDEX IF NOT EXISTS thread (thread); UPDATE uploads SET thread = ( SELECT thread FROM messages WHERE messages.id = uploads.container ); `, { multipleStatements: true }, ); }, ], [ 16, async () => { await dbQuery( SQL` ALTER TABLE cookies DROP COLUMN IF EXISTS public_key; ALTER TABLE cookies ADD COLUMN IF NOT EXISTS signed_identity_keys mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL; `, { multipleStatements: true }, ); }, ], [ 17, async () => { await dbQuery( SQL` ALTER TABLE cookies DROP INDEX device_token, DROP INDEX user_device_token; ALTER TABLE cookies MODIFY device_token mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, ADD UNIQUE KEY device_token (device_token(512)), ADD KEY user_device_token (user,device_token(512)); `, { multipleStatements: true }, ); }, ], [18, updateRolesAndPermissionsForAllThreads], [19, updateRolesAndPermissionsForAllThreads], [ 20, async () => { await dbQuery(SQL` ALTER TABLE threads ADD COLUMN IF NOT EXISTS avatar varchar(191) COLLATE utf8mb4_bin DEFAULT NULL; `); }, ], [ 21, async () => { await dbQuery(SQL` ALTER TABLE reports DROP INDEX IF EXISTS user, ADD INDEX IF NOT EXISTS user_type_platform_creation_time (user, type, platform, creation_time); `); }, ], [ 22, async () => { await dbQuery( SQL` ALTER TABLE cookies MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE entries MODIFY creator varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE focused MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE memberships MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE messages MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE notifications MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE reports MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE revisions MODIFY author varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE sessions MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE threads MODIFY creator varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE updates MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE uploads MODIFY uploader varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE users MODIFY id varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE relationships_undirected MODIFY user1 varchar(255) CHARSET latin1 COLLATE latin1_bin, MODIFY user2 varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE relationships_directed MODIFY user1 varchar(255) CHARSET latin1 COLLATE latin1_bin, MODIFY user2 varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE user_messages MODIFY recipient varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE settings MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; ALTER TABLE policy_acknowledgments MODIFY user varchar(255) CHARSET latin1 COLLATE latin1_bin; `, { multipleStatements: true }, ); }, ], [23, updateRolesAndPermissionsForAllThreads], [24, updateRolesAndPermissionsForAllThreads], [ 25, async () => { await dbQuery( SQL` CREATE TABLE IF NOT EXISTS message_search ( original_message_id bigint(20) NOT NULL, message_id bigint(20) NOT NULL, processed_content mediumtext COLLATE utf8mb4_bin ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; ALTER TABLE message_search ADD PRIMARY KEY (original_message_id), ADD FULLTEXT INDEX processed_content (processed_content); `, { multipleStatements: true }, ); }, ], [26, processMessagesInDBForSearch], [ 27, async () => { await dbQuery(SQL` ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned tinyint(1) UNSIGNED NOT NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS pin_time bigint(20) DEFAULT NULL, ADD INDEX IF NOT EXISTS thread_pinned (thread, pinned); `); }, ], [ 28, async () => { await dbQuery(SQL` ALTER TABLE threads ADD COLUMN IF NOT EXISTS pinned_count int UNSIGNED NOT NULL DEFAULT 0; `); }, ], [29, updateRolesAndPermissionsForAllThreads], [ 30, async () => { await dbQuery(SQL`DROP TABLE versions;`); }, ], [ 31, async () => { await dbQuery( SQL` CREATE TABLE IF NOT EXISTS invite_links ( id bigint(20) NOT NULL, name varchar(255) CHARSET latin1 NOT NULL, \`primary\` tinyint(1) UNSIGNED NOT NULL DEFAULT 0, role bigint(20) NOT NULL, community bigint(20) NOT NULL, expiration_time bigint(20), limit_of_uses int UNSIGNED, number_of_uses int UNSIGNED NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8; ALTER TABLE invite_links ADD PRIMARY KEY (id), ADD UNIQUE KEY (name), ADD INDEX community_primary (community, \`primary\`); `, { multipleStatements: true }, ); }, ], [ 32, async () => { await dbQuery(SQL` UPDATE messages SET target_message = JSON_VALUE(content, "$.sourceMessageID") WHERE type = ${messageTypes.SIDEBAR_SOURCE}; `); }, ], [ 33, async () => { await dbQuery( SQL` CREATE TABLE IF NOT EXISTS olm_sessions ( cookie_id bigint(20) NOT NULL, is_content tinyint(1) NOT NULL, version bigint(20) NOT NULL, pickled_olm_session text CHARACTER SET latin1 COLLATE latin1_bin NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_bin; ALTER TABLE olm_sessions ADD PRIMARY KEY (cookie_id, is_content); `, { multipleStatements: true }, ); }, ], [ 34, async () => { await dbQuery( SQL` CREATE TABLE IF NOT EXISTS olm_accounts ( is_content tinyint(1) NOT NULL, version bigint(20) NOT NULL, pickling_key text CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, pickled_olm_account text CHARACTER SET latin1 COLLATE latin1_bin NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_bin; ALTER TABLE olm_accounts ADD PRIMARY KEY (is_content); `, { multipleStatements: true }, ); }, ], [ 35, async () => { await createOlmAccounts(); }, ], [36, updateRolesAndPermissionsForAllThreads], [ 37, async () => { await dbQuery( SQL` DELETE FROM olm_accounts; DELETE FROM olm_sessions; `, { multipleStatements: true }, ); await createOlmAccounts(); }, ], [ 38, async () => { const [result] = await dbQuery(SQL` SELECT t.id FROM threads t INNER JOIN memberships m ON m.thread = t.id AND m.role > 0 INNER JOIN users u ON u.id = m.user WHERE t.type = ${threadTypes.PRIVATE} AND t.name = u.ethereum_address `); const threadIDs = result.map(({ id }) => id.toString()); while (threadIDs.length > 0) { // Batch 10 updateThread calls at a time const batch = threadIDs.splice(0, 10); await Promise.all( batch.map(threadID => updateThread( botViewer, { threadID, changes: { name: '', }, }, { silenceMessages: true, ignorePermissions: true, }, ), ), ); } }, ], [39, ensureUserCredentials], [ 40, // Tokens from identity service are 512 characters long () => dbQuery( SQL` ALTER TABLE metadata MODIFY COLUMN data VARCHAR(1023) `, ), ], [ 41, () => dbQuery( SQL` ALTER TABLE memberships DROP INDEX user, ADD KEY user_role_thread (user, role, thread) `, ), ], [ 42, async () => { await dbQuery(SQL` ALTER TABLE roles ADD UNIQUE KEY thread_name (thread, name); `); }, ], [ 43, () => dbQuery( SQL` UPDATE threads SET pinned_count = ( SELECT COUNT(*) FROM messages WHERE messages.thread = threads.id AND messages.pinned = 1 ) `, ), ], [ 44, async () => { const { SIDEBAR_SOURCE, TOGGLE_PIN } = messageTypes; const [result] = await dbQuery(SQL` SELECT m1.thread FROM messages m1 LEFT JOIN messages m2 ON m2.id = m1.target_message WHERE m1.type = ${SIDEBAR_SOURCE} AND m2.type = ${TOGGLE_PIN} `); const threadIDs = new Set(); for (const row of result) { threadIDs.add(row.thread.toString()); } await Promise.all( [...threadIDs].map(threadID => deleteThread(botViewer, { threadID }, { ignorePermissions: true }), ), ); }, ], [ 45, () => dbQuery( SQL` ALTER TABLE uploads CHARSET utf8mb4 COLLATE utf8mb4_bin, MODIFY COLUMN type varchar(255) CHARSET latin1 COLLATE latin1_swedish_ci NOT NULL, MODIFY COLUMN filename varchar(255) CHARSET utf8mb4 COLLATE utf8mb4_bin NOT NULL, MODIFY COLUMN mime varchar(255) CHARSET latin1 COLLATE latin1_swedish_ci NOT NULL, MODIFY COLUMN secret varchar(255) CHARSET latin1 COLLATE latin1_swedish_ci NOT NULL; `, ), ], [ 46, async () => { try { const [content, notif] = await Promise.all([ fetchOlmAccount('content'), fetchOlmAccount('notifications'), ]); await publishPrekeysToIdentity(content.account, notif.account); } catch (e) { console.warn('Encountered error while trying to publish prekeys', e); if (process.env.NODE_ENV !== 'development') { throw e; } } }, ], [ 47, () => dbQuery(SQL`ALTER TABLE cookies MODIFY COLUMN hash char(64) NOT NULL`), ], [ 48, async () => { const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; const query = SQL` UPDATE memberships mm LEFT JOIN ( SELECT m.thread, MAX(m.id) AS message FROM messages m WHERE m.type != ${messageTypes.CREATE_SUB_THREAD} - AND m.thread = ${genesis.id} + AND m.thread = ${genesis().id} GROUP BY m.thread ) all_users_query ON mm.thread = all_users_query.thread LEFT JOIN ( SELECT m.thread, stm.user, MAX(m.id) AS message FROM messages m - LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} + LEFT JOIN memberships stm ON m.type = ${ + messageTypes.CREATE_SUB_THREAD + } AND stm.thread = m.content WHERE JSON_EXTRACT(stm.permissions, ${visibleExtractString}) IS TRUE - AND m.thread = ${genesis.id} + AND m.thread = ${genesis().id} GROUP BY m.thread, stm.user ) last_subthread_message_for_user_query ON mm.thread = last_subthread_message_for_user_query.thread AND mm.user = last_subthread_message_for_user_query.user SET mm.last_message = GREATEST(COALESCE(all_users_query.message, 0), COALESCE(last_subthread_message_for_user_query.message, 0)) - WHERE mm.thread = ${genesis.id}; + WHERE mm.thread = ${genesis().id}; `; await dbQuery(query); }, ], [ 49, async () => { if (isDockerEnvironment()) { return; } const defaultCorsConfig = { domain: 'http://localhost:3000', }; await writeJSONToFile(defaultCorsConfig, 'facts/webapp_cors.json'); }, ], [ 50, async () => { await moveToNonApacheConfig('facts/webapp_url.json', '/webapp/'); await moveToNonApacheConfig('facts/keyserver_url.json', '/keyserver/'); }, ], [ 51, async () => { if (permissionsToRemoveInMigration.length === 0) { return; } const setClause = SQL`permissions = JSON_REMOVE(permissions, ${permissionsToRemoveInMigration.map( path => `$.${path}`, )})`; const updateQuery = SQL` UPDATE roles r LEFT JOIN threads t ON t.id = r.thread `; updateQuery.append(SQL`SET `.append(setClause)); updateQuery.append(SQL` WHERE r.name != 'Admins' AND (t.type = ${threadTypes.COMMUNITY_ROOT} OR t.type = ${threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT}) `); await dbQuery(updateQuery); }, ], [ 52, async () => { await dbQuery( SQL` ALTER TABLE roles ADD COLUMN IF NOT EXISTS special_role tinyint(2) UNSIGNED DEFAULT NULL `, ); await updateRolesAndPermissionsForAllThreads(); }, ], [ 53, async () => dbQuery(SQL` ALTER TABLE invite_links ADD COLUMN blob_holder char(36) CHARSET latin1 `), ], [ 54, async () => { await dbQuery( SQL` ALTER TABLE roles ADD COLUMN IF NOT EXISTS special_role tinyint(2) UNSIGNED DEFAULT NULL, DROP KEY IF EXISTS thread, ADD KEY IF NOT EXISTS thread_special_role (thread, special_role); UPDATE roles r JOIN threads t ON r.id = t.default_role SET r.special_role = ${specialRoles.DEFAULT_ROLE}; `, { multipleStatements: true }, ); }, ], [ 55, async () => { await dbQuery( SQL` ALTER TABLE threads DROP COLUMN IF EXISTS default_role `, ); }, ], [ 56, async () => { await dbQuery( SQL` UPDATE roles SET special_role = ${specialRoles.ADMIN_ROLE} WHERE name = 'Admins' `, ); }, ], [57, synchronizeInviteLinksWithBlobs], ]); const newDatabaseVersion: number = Math.max(...migrations.keys()); async function writeJSONToFile(data: any, filePath: string): Promise { console.warn(`updating ${filePath} to ${JSON.stringify(data)}`); const fileHandle = await fs.promises.open(filePath, 'w'); await fileHandle.writeFile(JSON.stringify(data, null, ' '), 'utf8'); await fileHandle.close(); } async function makeSureBaseRoutePathExists(filePath: string): Promise { let readFile, json; try { readFile = await fs.promises.open(filePath, 'r'); const contents = await readFile.readFile('utf8'); json = JSON.parse(contents); } catch { return; } finally { if (readFile) { await readFile.close(); } } if (json.baseRoutePath) { return; } let baseRoutePath; if (json.baseDomain === 'http://localhost') { baseRoutePath = json.basePath; } else if (filePath.endsWith('commapp_url.json')) { baseRoutePath = '/commweb/'; } else { baseRoutePath = '/'; } const newJSON = { ...json, baseRoutePath }; console.warn(`updating ${filePath} to ${JSON.stringify(newJSON)}`); await writeJSONToFile(newJSON, filePath); } async function fixBaseRoutePathForLocalhost(filePath: string): Promise { let readFile, json; try { readFile = await fs.promises.open(filePath, 'r'); const contents = await readFile.readFile('utf8'); json = JSON.parse(contents); } catch { return; } finally { if (readFile) { await readFile.close(); } } if (json.baseDomain !== 'http://localhost') { return; } const baseRoutePath = '/'; json = { ...json, baseRoutePath }; console.warn(`updating ${filePath} to ${JSON.stringify(json)}`); await writeJSONToFile(json, filePath); } async function moveToNonApacheConfig( filePath: string, routePath: string, ): Promise { if (isDockerEnvironment()) { return; } // Since the non-Apache config is so opinionated, just write expected config const newJSON = { baseDomain: 'http://localhost:3000', basePath: routePath, baseRoutePath: routePath, https: false, proxy: 'none', }; await writeJSONToFile(newJSON, filePath); } async function writeSquadCalRoute(filePath: string): Promise { if (isDockerEnvironment()) { return; } // Since the non-Apache config is so opinionated, just write expected config const newJSON = { baseDomain: 'http://localhost:3000', basePath: '/comm/', baseRoutePath: '/', https: false, proxy: 'apache', }; await writeJSONToFile(newJSON, filePath); } async function createOlmAccounts() { const [pickledContentAccount, pickledNotificationsAccount] = await Promise.all([createPickledOlmAccount(), createPickledOlmAccount()]); await dbQuery( SQL` INSERT INTO olm_accounts (is_content, version, pickling_key, pickled_olm_account) VALUES ( TRUE, 0, ${pickledContentAccount.picklingKey}, ${pickledContentAccount.pickledAccount} ), ( FALSE, 0, ${pickledNotificationsAccount.picklingKey}, ${pickledNotificationsAccount.pickledAccount} ); `, ); } function isDockerEnvironment(): boolean { return !!process.env.COMM_DATABASE_HOST; } export { migrations, newDatabaseVersion, createOlmAccounts }; diff --git a/keyserver/src/database/setup-db.js b/keyserver/src/database/setup-db.js index f06c67102..fb0aaa403 100644 --- a/keyserver/src/database/setup-db.js +++ b/keyserver/src/database/setup-db.js @@ -1,484 +1,484 @@ // @flow import ashoat from 'lib/facts/ashoat.js'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import { usernameMaxLength } from 'lib/shared/account-utils.js'; import { sortIDs } from 'lib/shared/relationship-utils.js'; import { undirectedStatus } from 'lib/types/relationship-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { createThread } from '../creators/thread-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { updateDBVersion } from '../database/db-version.js'; import { newDatabaseVersion, createOlmAccounts, } from '../database/migration-config.js'; import { createScriptViewer } from '../session/scripts.js'; import { ensureUserCredentials } from '../user/checks.js'; async function setupDB() { await ensureUserCredentials(); await createTables(); await createUsers(); await createThreads(); await setUpMetadataTable(); await createOlmAccounts(); } async function createTables() { await dbQuery( SQL` CREATE TABLE cookies ( id bigint(20) NOT NULL, hash char(64) NOT NULL, user varchar(255) CHARSET latin1 COLLATE latin1_bin DEFAULT NULL, platform varchar(255) DEFAULT NULL, creation_time bigint(20) NOT NULL, last_used bigint(20) NOT NULL, device_token mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, versions json DEFAULT NULL, device_id varchar(255) DEFAULT NULL, signed_identity_keys mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, social_proof mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, \`primary\` TINYINT(1) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE days ( id bigint(20) NOT NULL, date date NOT NULL, thread bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE entries ( id bigint(20) NOT NULL, day bigint(20) NOT NULL, text mediumtext COLLATE utf8mb4_bin NOT NULL, creator varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, creation_time bigint(20) NOT NULL, last_update bigint(20) NOT NULL, deleted tinyint(1) UNSIGNED NOT NULL, creation varchar(255) COLLATE utf8mb4_bin DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE focused ( user varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, session bigint(20) NOT NULL, thread bigint(20) NOT NULL, time bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE ids ( id bigint(20) NOT NULL, table_name varchar(255) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE memberships ( thread bigint(20) NOT NULL, user varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, role bigint(20) NOT NULL, permissions json DEFAULT NULL, permissions_for_children json DEFAULT NULL, creation_time bigint(20) NOT NULL, subscription json NOT NULL, last_message bigint(20) NOT NULL DEFAULT 0, last_read_message bigint(20) NOT NULL DEFAULT 0, sender tinyint(1) UNSIGNED NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE messages ( id bigint(20) NOT NULL, thread bigint(20) NOT NULL, user varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, type tinyint(3) UNSIGNED NOT NULL, content mediumtext COLLATE utf8mb4_bin, time bigint(20) NOT NULL, creation varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, target_message bigint(20) DEFAULT NULL, pinned tinyint(1) UNSIGNED NOT NULL DEFAULT 0, pin_time bigint(20) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE notifications ( id bigint(20) NOT NULL, user varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, thread bigint(20) DEFAULT NULL, message bigint(20) DEFAULT NULL, collapse_key varchar(255) DEFAULT NULL, delivery json NOT NULL, rescinded tinyint(1) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE reports ( id bigint(20) NOT NULL, user varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, type tinyint(3) UNSIGNED NOT NULL, platform varchar(255) NOT NULL, report json NOT NULL, creation_time bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE revisions ( id bigint(20) NOT NULL, entry bigint(20) NOT NULL, author varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, text mediumtext COLLATE utf8mb4_bin NOT NULL, creation_time bigint(20) NOT NULL, session bigint(20) NOT NULL, last_update bigint(20) NOT NULL, deleted tinyint(1) UNSIGNED NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE roles ( id bigint(20) NOT NULL, thread bigint(20) NOT NULL, name varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, permissions json NOT NULL, creation_time bigint(20) NOT NULL, special_role tinyint(2) UNSIGNED DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE sessions ( id bigint(20) NOT NULL, user varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, cookie bigint(20) NOT NULL, query json NOT NULL, creation_time bigint(20) NOT NULL, last_update bigint(20) NOT NULL, last_validated bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE threads ( id bigint(20) NOT NULL, type tinyint(3) NOT NULL, name varchar(191) COLLATE utf8mb4_bin DEFAULT NULL, description mediumtext COLLATE utf8mb4_bin, parent_thread_id bigint(20) DEFAULT NULL, containing_thread_id bigint(20) DEFAULT NULL, community bigint(20) DEFAULT NULL, depth int UNSIGNED NOT NULL DEFAULT 0, creator varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, creation_time bigint(20) NOT NULL, color char(6) COLLATE utf8mb4_bin NOT NULL, source_message bigint(20) DEFAULT NULL UNIQUE, replies_count int UNSIGNED NOT NULL DEFAULT 0, avatar varchar(191) COLLATE utf8mb4_bin DEFAULT NULL, pinned_count int UNSIGNED NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE updates ( id bigint(20) NOT NULL, user varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, type tinyint(3) UNSIGNED NOT NULL, \`key\` bigint(20) DEFAULT NULL, updater bigint(20) DEFAULT NULL, target bigint(20) DEFAULT NULL, content mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, time bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE uploads ( id bigint(20) NOT NULL, thread bigint(20) DEFAULT NULL, uploader varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, container bigint(20) DEFAULT NULL, type varchar(255) CHARSET latin1 COLLATE latin1_swedish_ci NOT NULL, filename varchar(255) NOT NULL, mime varchar(255) CHARSET latin1 COLLATE latin1_swedish_ci NOT NULL, content longblob NOT NULL, secret varchar(255) CHARSET latin1 COLLATE latin1_swedish_ci NOT NULL, creation_time bigint(20) NOT NULL, extra json DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE users ( id varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, username varchar(${usernameMaxLength}) COLLATE utf8mb4_bin NOT NULL, hash char(60) COLLATE utf8mb4_bin DEFAULT NULL, avatar varchar(191) COLLATE utf8mb4_bin DEFAULT NULL, ethereum_address char(42) DEFAULT NULL, creation_time bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE relationships_undirected ( user1 varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, user2 varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, status tinyint(1) UNSIGNED NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE relationships_directed ( user1 varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, user2 varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, status tinyint(1) UNSIGNED NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1; CREATE TABLE one_time_keys ( session bigint(20) NOT NULL, one_time_key char(43) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE user_messages ( recipient varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, thread bigint(20) NOT NULL, message bigint(20) NOT NULL, time bigint(20) NOT NULL, data mediumtext COLLATE utf8mb4_bin DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE settings ( user varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, name varchar(255) NOT NULL, data mediumtext COLLATE utf8mb4_bin DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE metadata ( name varchar(255) NOT NULL, data varchar(1023) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE policy_acknowledgments ( user varchar(255) CHARSET latin1 COLLATE latin1_bin NOT NULL, policy varchar(255) NOT NULL, date bigint(20) NOT NULL, confirmed tinyint(1) UNSIGNED NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE siwe_nonces ( nonce char(17) NOT NULL, creation_time bigint(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE message_search ( original_message_id bigint(20) NOT NULL, message_id bigint(20) NOT NULL, processed_content mediumtext COLLATE utf8mb4_bin ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE TABLE invite_links ( id bigint(20) NOT NULL, name varchar(255) CHARSET latin1 NOT NULL, \`primary\` tinyint(1) UNSIGNED NOT NULL DEFAULT 0, role bigint(20) NOT NULL, community bigint(20) NOT NULL, expiration_time bigint(20), limit_of_uses int UNSIGNED, number_of_uses int UNSIGNED NOT NULL DEFAULT 0, blob_holder char(36) CHARSET latin1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE olm_sessions ( cookie_id bigint(20) NOT NULL, is_content tinyint(1) NOT NULL, version bigint(20) NOT NULL, pickled_olm_session text CHARACTER SET latin1 COLLATE latin1_bin NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_bin; CREATE TABLE olm_accounts ( is_content tinyint(1) NOT NULL, version bigint(20) NOT NULL, pickling_key text CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, pickled_olm_account text CHARACTER SET latin1 COLLATE latin1_bin NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_bin; ALTER TABLE cookies ADD PRIMARY KEY (id), ADD UNIQUE KEY device_token (device_token(512)), ADD KEY user_device_token (user,device_token(512)); ALTER TABLE days ADD PRIMARY KEY (id), ADD UNIQUE KEY date_thread (date,thread) USING BTREE; ALTER TABLE entries ADD PRIMARY KEY (id), ADD UNIQUE KEY creator_creation (creator,creation), ADD KEY day (day); ALTER TABLE focused ADD UNIQUE KEY user_cookie_thread (user,session,thread), ADD KEY thread_user (thread,user); ALTER TABLE ids ADD PRIMARY KEY (id); ALTER TABLE memberships ADD UNIQUE KEY thread_user (thread, user) USING BTREE, ADD KEY role (role) USING BTREE, ADD KEY user_role_thread (user, role, thread); ALTER TABLE messages ADD PRIMARY KEY (id), ADD UNIQUE KEY user_creation (user,creation), ADD KEY thread (thread), ADD INDEX target_message (target_message), ADD INDEX thread_pinned (thread, pinned); ALTER TABLE notifications ADD PRIMARY KEY (id), ADD KEY rescinded_user_collapse_key (rescinded,user,collapse_key) USING BTREE, ADD KEY thread (thread), ADD KEY rescinded_user_thread_message (rescinded,user,thread,message) USING BTREE; ALTER TABLE notifications ADD INDEX user (user); ALTER TABLE reports ADD PRIMARY KEY (id), ADD INDEX user_type_platform_creation_time (user, type, platform, creation_time); ALTER TABLE revisions ADD PRIMARY KEY (id), ADD KEY entry (entry); ALTER TABLE roles ADD PRIMARY KEY (id), ADD KEY thread_special_role (thread, special_role), ADD UNIQUE KEY thread_name (thread, name); ALTER TABLE sessions ADD PRIMARY KEY (id), ADD KEY user (user); ALTER TABLE threads ADD PRIMARY KEY (id), ADD INDEX parent_thread_id (parent_thread_id), ADD INDEX containing_thread_id (containing_thread_id), ADD INDEX community (community); ALTER TABLE updates ADD PRIMARY KEY (id), ADD INDEX user_time (user,time), ADD INDEX target_time (target, time), ADD INDEX user_key_target_type_time (user, \`key\`, target, type, time), ADD INDEX user_key_type_time (user, \`key\`, type, time), ADD INDEX user_key_time (user, \`key\`, time); ALTER TABLE uploads ADD PRIMARY KEY (id), ADD INDEX container (container), ADD INDEX thread (thread); ALTER TABLE users ADD PRIMARY KEY (id), ADD UNIQUE KEY username (username); ALTER TABLE relationships_undirected ADD UNIQUE KEY user1_user2 (user1,user2), ADD UNIQUE KEY user2_user1 (user2,user1); ALTER TABLE relationships_directed ADD UNIQUE KEY user1_user2 (user1,user2), ADD UNIQUE KEY user2_user1 (user2,user1); ALTER TABLE one_time_keys ADD PRIMARY KEY (session, one_time_key); ALTER TABLE user_messages ADD INDEX recipient_time (recipient, time), ADD INDEX recipient_thread_time (recipient, thread, time), ADD INDEX thread (thread), ADD PRIMARY KEY (recipient, message); ALTER TABLE ids MODIFY id bigint(20) NOT NULL AUTO_INCREMENT; ALTER TABLE settings ADD PRIMARY KEY (user, name); ALTER TABLE metadata ADD PRIMARY KEY (name); ALTER TABLE policy_acknowledgments ADD PRIMARY KEY (user, policy); ALTER TABLE siwe_nonces ADD PRIMARY KEY (nonce); ALTER TABLE message_search ADD PRIMARY KEY (original_message_id), ADD FULLTEXT INDEX processed_content (processed_content); ALTER TABLE invite_links ADD PRIMARY KEY (id), ADD UNIQUE KEY (name), ADD INDEX community_primary (community, \`primary\`); ALTER TABLE olm_sessions ADD PRIMARY KEY (cookie_id, is_content); ALTER TABLE olm_accounts ADD PRIMARY KEY (is_content); `, { multipleStatements: true }, ); } async function createUsers() { const [user1, user2] = sortIDs(bots.commbot.userID, ashoat.id); await dbQuery( SQL` INSERT INTO ids (id, table_name) VALUES (${bots.commbot.userID}, 'users'), (${ashoat.id}, 'users'); INSERT INTO users (id, username, hash, avatar, creation_time) VALUES (${bots.commbot.userID}, 'commbot', '', NULL, 1530049900980), (${ashoat.id}, 'ashoat', '', NULL, 1463588881886); INSERT INTO relationships_undirected (user1, user2, status) VALUES (${user1}, ${user2}, ${undirectedStatus.KNOW_OF}); `, { multipleStatements: true }, ); } const createThreadOptions = { forceAddMembers: true }; async function createThreads() { const insertIDsPromise = dbQuery(SQL` INSERT INTO ids (id, table_name) VALUES - (${genesis.id}, 'threads'), + (${genesis().id}, 'threads'), (${bots.commbot.staffThreadID}, 'threads') `); const ashoatViewer = createScriptViewer(ashoat.id); const createGenesisPromise = createThread( ashoatViewer, { - id: genesis.id, + id: genesis().id, type: threadTypes.GENESIS, - name: genesis.name, - description: genesis.description, + name: genesis().name, + description: genesis().description, initialMemberIDs: [bots.commbot.userID], }, createThreadOptions, ); await Promise.all([insertIDsPromise, createGenesisPromise]); const commbotViewer = createScriptViewer(bots.commbot.userID); await createThread( commbotViewer, { id: bots.commbot.staffThreadID, type: threadTypes.COMMUNITY_SECRET_SUBTHREAD, initialMemberIDs: [ashoat.id], }, createThreadOptions, ); } async function setUpMetadataTable() { await updateDBVersion(newDatabaseVersion); } export { setupDB }; diff --git a/keyserver/src/fetchers/thread-permission-fetchers.js b/keyserver/src/fetchers/thread-permission-fetchers.js index 007fa75f1..cabf0c992 100644 --- a/keyserver/src/fetchers/thread-permission-fetchers.js +++ b/keyserver/src/fetchers/thread-permission-fetchers.js @@ -1,368 +1,368 @@ // @flow import genesis from 'lib/facts/genesis.js'; import { permissionLookup, makePermissionsBlob, getRoleForPermissions, } from 'lib/permissions/thread-permissions.js'; import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils.js'; import { threadFrozenDueToBlock, permissionsDisabledByBlock, } from 'lib/shared/thread-utils.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; import type { ThreadPermission, ThreadPermissionsBlob, ThreadRolePermissionsBlob, } from 'lib/types/thread-permission-types.js'; import type { ThreadType } from 'lib/types/thread-types-enum.js'; import { fetchThreadInfos } from './thread-fetchers.js'; import { fetchKnownUserInfos } from './user-fetchers.js'; import { dbQuery, SQL } from '../database/database.js'; import type { Viewer } from '../session/viewer.js'; // Note that it's risky to verify permissions by inspecting the blob directly. // There are other factors that can override permissions in the permissions // blob, such as when one user blocks another. It's always better to go through // checkThreads and friends, or by looking at the ThreadInfo through // threadHasPermission. async function fetchThreadPermissionsBlob( viewer: Viewer, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT permissions FROM memberships WHERE thread = ${threadID} AND user = ${viewerID} `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const row = result[0]; return JSON.parse(row.permissions); } function checkThreadPermission( viewer: Viewer, threadID: string, permission: ThreadPermission, ): Promise { return checkThread(viewer, threadID, [{ check: 'permission', permission }]); } function viewerIsMember(viewer: Viewer, threadID: string): Promise { return checkThread(viewer, threadID, [{ check: 'is_member' }]); } type Check = | { +check: 'is_member' } | { +check: 'permission', +permission: ThreadPermission }; function isThreadValid( permissions: ?ThreadPermissionsBlob, role: number, checks: $ReadOnlyArray, ): boolean { for (const check of checks) { if (check.check === 'is_member') { if (role <= 0) { return false; } } else if (check.check === 'permission') { if (!permissionLookup(permissions, check.permission)) { return false; } } } return true; } async function checkThreads( viewer: Viewer, threadIDs: $ReadOnlyArray, checks: $ReadOnlyArray, ): Promise> { if (viewer.isScriptViewer) { // script viewers are all-powerful return new Set(threadIDs); } const threadRows = await getValidThreads(viewer, threadIDs, checks); return new Set(threadRows.map(row => row.threadID)); } type PartialMembershipRow = { +threadID: string, +role: number, +permissions: ThreadPermissionsBlob, }; async function getValidThreads( viewer: Viewer, threadIDs: $ReadOnlyArray, checks: $ReadOnlyArray, ): Promise { const query = SQL` SELECT thread AS threadID, permissions, role FROM memberships WHERE thread IN (${threadIDs}) AND user = ${viewer.userID} `; const permissionsToCheck = []; for (const check of checks) { if (check.check === 'permission') { permissionsToCheck.push(check.permission); } } const [[result], disabledThreadIDs] = await Promise.all([ dbQuery(query), checkThreadsFrozen(viewer, permissionsToCheck, threadIDs), ]); return result .map(row => ({ ...row, threadID: row.threadID.toString(), permissions: JSON.parse(row.permissions), })) .filter( row => isThreadValid(row.permissions, row.role, checks) && !disabledThreadIDs.has(row.threadID), ); } async function checkThreadsFrozen( viewer: Viewer, permissionsToCheck: $ReadOnlyArray, threadIDs: $ReadOnlyArray, ): Promise<$ReadOnlySet> { const threadIDsWithDisabledPermissions = new Set(); const permissionMightBeDisabled = permissionsToCheck.some(permission => permissionsDisabledByBlock.has(permission), ); if (!permissionMightBeDisabled) { return threadIDsWithDisabledPermissions; } const [{ threadInfos }, userInfos] = await Promise.all([ fetchThreadInfos(viewer, { threadIDs: new Set(threadIDs) }), fetchKnownUserInfos(viewer), ]); for (const threadID in threadInfos) { const blockedThread = threadFrozenDueToBlock( threadInfos[threadID], viewer.id, userInfos, ); if (blockedThread) { threadIDsWithDisabledPermissions.add(threadID); } } return threadIDsWithDisabledPermissions; } async function checkIfThreadIsBlocked( viewer: Viewer, threadID: string, permission: ThreadPermission, ): Promise { const disabledThreadIDs = await checkThreadsFrozen( viewer, [permission], [threadID], ); return disabledThreadIDs.has(threadID); } async function checkThread( viewer: Viewer, threadID: string, checks: $ReadOnlyArray, ): Promise { const validThreads = await checkThreads(viewer, [threadID], checks); return validThreads.has(threadID); } // We pass this into getRoleForPermissions in order to check if a hypothetical // permissions blob would block membership by returning a non-positive result. // It doesn't matter what value we pass in, as long as it's positive. const arbitraryPositiveRole = '1'; type ContainingStatus = 'member' | 'non-member' | 'no-containing-thread'; type CandidateMembers = { +[key: string]: ?$ReadOnlyArray, }; type ValidateCandidateMembersParams = { +threadType: ThreadType, +parentThreadID: ?string, +containingThreadID: ?string, +defaultRolePermissions: ThreadRolePermissionsBlob, }; type ValidateCandidateMembersOptions = { +requireRelationship?: boolean }; async function validateCandidateMembers( viewer: Viewer, candidates: CandidateMembers, params: ValidateCandidateMembersParams, options?: ValidateCandidateMembersOptions, ): Promise { const requireRelationship = options?.requireRelationship ?? true; const allCandidatesSet = new Set(); for (const key in candidates) { const candidateGroup = candidates[key]; if (!candidateGroup) { continue; } for (const candidate of candidateGroup) { allCandidatesSet.add(candidate); } } const allCandidates = [...allCandidatesSet]; const fetchMembersPromise = fetchKnownUserInfos(viewer, allCandidates); const parentPermissionsPromise = (async () => { const parentPermissions = {}; if (!params.parentThreadID || allCandidates.length === 0) { return parentPermissions; } const parentPermissionsQuery = SQL` SELECT user, permissions FROM memberships WHERE thread = ${params.parentThreadID} AND user IN (${allCandidates}) `; const [result] = await dbQuery(parentPermissionsQuery); for (const row of result) { parentPermissions[row.user.toString()] = JSON.parse(row.permissions); } return parentPermissions; })(); const memberOfContainingThreadPromise: Promise< Map, > = (async () => { const results = new Map(); if (allCandidates.length === 0) { return results; } if (!params.containingThreadID) { for (const userID of allCandidates) { results.set(userID, 'no-containing-thread'); } return results; } const memberOfContainingThreadQuery = SQL` SELECT user, role AS containing_role FROM memberships WHERE thread = ${params.containingThreadID} AND user IN (${allCandidates}) `; const [result] = await dbQuery(memberOfContainingThreadQuery); for (const row of result) { results.set( row.user.toString(), row.containing_role > 0 ? 'member' : 'non-member', ); } return results; })(); const [fetchedMembers, parentPermissions, memberOfContainingThread] = await Promise.all([ fetchMembersPromise, parentPermissionsPromise, memberOfContainingThreadPromise, ]); const ignoreMembers = new Set(); for (const memberID of allCandidates) { const member = fetchedMembers[memberID]; if (!member && requireRelationship) { ignoreMembers.add(memberID); continue; } const relationshipStatus = member?.relationshipStatus; const memberRelationshipHasBlock = !!( relationshipStatus && relationshipBlockedInEitherDirection(relationshipStatus) ); if (memberRelationshipHasBlock) { ignoreMembers.add(memberID); continue; } const permissionsFromParent = parentPermissions[memberID]; if (memberOfContainingThread.get(memberID) === 'non-member') { ignoreMembers.add(memberID); continue; } - const isParentThreadGenesis = params.parentThreadID === genesis.id; + const isParentThreadGenesis = params.parentThreadID === genesis().id; if ( (memberOfContainingThread.get(memberID) === 'no-containing-thread' || isParentThreadGenesis) && relationshipStatus !== userRelationshipStatus.FRIEND && requireRelationship ) { ignoreMembers.add(memberID); continue; } const permissions = makePermissionsBlob( params.defaultRolePermissions, permissionsFromParent, '-1', params.threadType, ); if (!permissions) { ignoreMembers.add(memberID); continue; } const targetRole = getRoleForPermissions( arbitraryPositiveRole, permissions, ); if (Number(targetRole) <= 0) { ignoreMembers.add(memberID); continue; } } if (ignoreMembers.size === 0) { return candidates; } const result: { [string]: ?$ReadOnlyArray } = {}; for (const key in candidates) { const candidateGroup = candidates[key]; if (!candidateGroup) { result[key] = candidateGroup; continue; } const resultForKey = []; for (const candidate of candidateGroup) { if (!ignoreMembers.has(candidate)) { resultForKey.push(candidate); } } result[key] = resultForKey; } return result; } export { fetchThreadPermissionsBlob, checkThreadPermission, viewerIsMember, checkThreads, getValidThreads, checkThread, checkIfThreadIsBlocked, validateCandidateMembers, }; diff --git a/keyserver/src/updaters/thread-permission-updaters.js b/keyserver/src/updaters/thread-permission-updaters.js index 4fb475fd3..871da6908 100644 --- a/keyserver/src/updaters/thread-permission-updaters.js +++ b/keyserver/src/updaters/thread-permission-updaters.js @@ -1,1410 +1,1410 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import { specialRoles } from 'lib/permissions/special-roles.js'; import { makePermissionsBlob, makePermissionsForChildrenBlob, getRoleForPermissions, } from 'lib/permissions/thread-permissions.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { ThreadPermissionsBlob, ThreadRolePermissionsBlob, } from 'lib/types/thread-permission-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadType, assertThreadType, } from 'lib/types/thread-types-enum.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import { type ServerUpdateInfo, type CreateUpdatesResult, type UpdateData, } from 'lib/types/update-types.js'; import { pushAll } from 'lib/utils/array.js'; import { ServerError } from 'lib/utils/errors.js'; import { updateChangedUndirectedRelationships } from './relationship-updaters.js'; import { createUpdates, type UpdatesForCurrentSession, } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchServerThreadInfos, rawThreadInfosFromServerThreadInfos, } from '../fetchers/thread-fetchers.js'; import { rescindPushNotifs } from '../push/rescind.js'; import { createScriptViewer } from '../session/scripts.js'; import type { Viewer } from '../session/viewer.js'; import { updateRoles } from '../updaters/role-updaters.js'; import DepthQueue from '../utils/depth-queue.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; export type MembershipRowToSave = { +operation: 'save', +intent: 'join' | 'leave' | 'none', +userID: string, +threadID: string, +userNeedsFullThreadDetails: boolean, +permissions: ?ThreadPermissionsBlob, +permissionsForChildren: ?ThreadPermissionsBlob, // null role represents by "0" +role: string, +oldRole: string, +unread?: boolean, }; type MembershipRowToDelete = { +operation: 'delete', +intent: 'join' | 'leave' | 'none', +userID: string, +threadID: string, +oldRole: string, }; export type MembershipRow = MembershipRowToSave | MembershipRowToDelete; export type MembershipChangeset = { +membershipRows: MembershipRow[], +relationshipChangeset: RelationshipChangeset, }; // 0 role means to remove the user from the thread // null role means to set the user to the default role // string role means to set the user to the role with that ID // -1 role means to set the user as a "ghost" (former member) type ChangeRoleOptions = { +setNewMembersToUnread?: boolean, +forcePermissionRecalculation?: boolean, }; type ChangeRoleMemberInfo = { permissionsFromParent?: ?ThreadPermissionsBlob, memberOfContainingThread?: boolean, }; type ExistingMembership = { +oldRole: string, +oldPermissions: ?ThreadPermissionsBlob, +oldPermissionsForChildren: ?ThreadPermissionsBlob, }; async function changeRole( threadID: string, userIDs: $ReadOnlyArray, role: string | -1 | 0 | null, options?: ChangeRoleOptions, ): Promise { const intent = role === -1 || role === 0 ? 'leave' : 'join'; const setNewMembersToUnread = options?.setNewMembersToUnread && intent === 'join'; const forcePermissionRecalculation = options?.forcePermissionRecalculation; if (userIDs.length === 0) { return { membershipRows: [], relationshipChangeset: new RelationshipChangeset(), }; } const membershipQuery = SQL` SELECT user, role, permissions, permissions_for_children FROM memberships WHERE thread = ${threadID} `; const parentMembershipQuery = SQL` SELECT pm.user, pm.permissions_for_children AS permissions_from_parent FROM threads t INNER JOIN memberships pm ON pm.thread = t.parent_thread_id WHERE t.id = ${threadID} AND - (pm.user IN (${userIDs}) OR t.parent_thread_id != ${genesis.id}) + (pm.user IN (${userIDs}) OR t.parent_thread_id != ${genesis().id}) `; const containingMembershipQuery = SQL` SELECT cm.user, cm.role AS containing_role FROM threads t INNER JOIN memberships cm ON cm.thread = t.containing_thread_id WHERE t.id = ${threadID} AND cm.user IN (${userIDs}) `; const containingMembershipPromise: Promise< $ReadOnlyArray<{ +user: number, +containing_role: number, }>, > = (async () => { if (intent === 'leave') { // Membership in the container only needs to be checked for members return []; } const [result] = await dbQuery(containingMembershipQuery); return result; })(); const [ [membershipResults], [parentMembershipResults], containingMembershipResults, roleThreadResult, ] = await Promise.all([ dbQuery(membershipQuery), dbQuery(parentMembershipQuery), containingMembershipPromise, changeRoleThreadQuery(threadID, role), ]); const { roleColumnValue: intendedRole, threadType, parentThreadID, hasContainingThreadID, rolePermissions: intendedRolePermissions, depth, } = roleThreadResult; const existingMembershipInfo = new Map(); for (const row of membershipResults) { const userID = row.user.toString(); existingMembershipInfo.set(userID, { oldRole: row.role.toString(), oldPermissions: JSON.parse(row.permissions), oldPermissionsForChildren: JSON.parse(row.permissions_for_children), }); } const ancestorMembershipInfo: Map = new Map(); for (const row of parentMembershipResults) { const userID = row.user.toString(); if (!userIDs.includes(userID)) { continue; } ancestorMembershipInfo.set(userID, { permissionsFromParent: JSON.parse(row.permissions_from_parent), }); } for (const row of containingMembershipResults) { const userID = row.user.toString(); const ancestorMembership = ancestorMembershipInfo.get(userID); const memberOfContainingThread = row.containing_role > 0; if (ancestorMembership) { ancestorMembership.memberOfContainingThread = memberOfContainingThread; } else { ancestorMembershipInfo.set(userID, { memberOfContainingThread, }); } } const relationshipChangeset = new RelationshipChangeset(); const existingMemberIDs = [...existingMembershipInfo.keys()]; - if (threadID !== genesis.id) { + if (threadID !== genesis().id) { relationshipChangeset.setAllRelationshipsExist(existingMemberIDs); } const parentMemberIDs = parentMembershipResults.map(row => row.user.toString(), ); - if (parentThreadID && parentThreadID !== genesis.id) { + if (parentThreadID && parentThreadID !== genesis().id) { relationshipChangeset.setAllRelationshipsExist(parentMemberIDs); } const membershipRows: Array = []; const toUpdateDescendants = new Map(); for (const userID of userIDs) { const existingMembership = existingMembershipInfo.get(userID); const oldRole = existingMembership?.oldRole ?? '-1'; const oldPermissions = existingMembership?.oldPermissions ?? null; const oldPermissionsForChildren = existingMembership?.oldPermissionsForChildren ?? null; if ( existingMembership && oldRole === intendedRole && !forcePermissionRecalculation ) { // If the old role is the same as the new one, we have nothing to update continue; } else if (Number(oldRole) > 0 && role === null) { // In the case where we're just trying to add somebody to a thread, if // they already have a role with a nonzero role then we don't need to do // anything continue; } let permissionsFromParent = null; let memberOfContainingThread = false; const ancestorMembership = ancestorMembershipInfo.get(userID); if (ancestorMembership) { permissionsFromParent = ancestorMembership.permissionsFromParent; memberOfContainingThread = !!ancestorMembership.memberOfContainingThread; } if (!hasContainingThreadID) { memberOfContainingThread = true; } const rolePermissions = memberOfContainingThread ? intendedRolePermissions : null; const targetRole = memberOfContainingThread ? intendedRole : '-1'; const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, threadType, ); const permissionsForChildren = makePermissionsForChildrenBlob(permissions); const newRole = getRoleForPermissions(targetRole, permissions); const userBecameMember = Number(oldRole) <= 0 && Number(newRole) > 0; const userLostMembership = Number(oldRole) > 0 && Number(newRole) <= 0; if ( (intent === 'join' && Number(newRole) <= 0) || (intent === 'leave' && Number(newRole) > 0) ) { throw new ServerError('invalid_parameters'); } else if (intendedRole !== newRole) { console.warn( `changeRole called for role=${intendedRole}, but ended up setting ` + `role=${newRole} for userID ${userID} and threadID ${threadID}, ` + 'probably because KNOW_OF permission was unexpectedly present or ' + 'missing', ); } if ( existingMembership && _isEqual(permissions)(oldPermissions) && oldRole === newRole ) { // This thread and all of its descendants need no updates for this user, // since the corresponding memberships row is unchanged by this operation continue; } if (permissions) { membershipRows.push({ operation: 'save', intent, userID, threadID, userNeedsFullThreadDetails: userBecameMember, permissions, permissionsForChildren, role: newRole, oldRole, unread: userBecameMember && setNewMembersToUnread, }); } else { membershipRows.push({ operation: 'delete', intent, userID, threadID, oldRole, }); } - if (permissions && !existingMembership && threadID !== genesis.id) { + if (permissions && !existingMembership && threadID !== genesis().id) { relationshipChangeset.setRelationshipsNeeded(userID, existingMemberIDs); } if ( userLostMembership || !_isEqual(permissionsForChildren)(oldPermissionsForChildren) ) { toUpdateDescendants.set(userID, { userIsMember: Number(newRole) > 0, permissionsForChildren, }); } } if (toUpdateDescendants.size > 0) { const { membershipRows: descendantMembershipRows, relationshipChangeset: descendantRelationshipChangeset, } = await updateDescendantPermissions({ threadID, depth, changesByUser: toUpdateDescendants, }); pushAll(membershipRows, descendantMembershipRows); relationshipChangeset.addAll(descendantRelationshipChangeset); } return { membershipRows, relationshipChangeset }; } type RoleThreadResult = { +roleColumnValue: string, +depth: number, +threadType: ThreadType, +parentThreadID: ?string, +hasContainingThreadID: boolean, +rolePermissions: ?ThreadRolePermissionsBlob, }; async function changeRoleThreadQuery( threadID: string, role: string | -1 | 0 | null, ): Promise { if (role === 0 || role === -1) { const query = SQL` SELECT type, depth, parent_thread_id, containing_thread_id FROM threads WHERE id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('internal_error'); } const row = result[0]; return { roleColumnValue: role.toString(), depth: row.depth, threadType: assertThreadType(row.type), parentThreadID: row.parent_thread_id ? row.parent_thread_id.toString() : null, hasContainingThreadID: row.containing_thread_id !== null, rolePermissions: null, }; } else if (role !== null) { const query = SQL` SELECT t.type, t.depth, t.parent_thread_id, t.containing_thread_id, r.permissions FROM threads t INNER JOIN roles r ON r.thread = t.id AND r.id = ${role} WHERE t.id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('internal_error'); } const row = result[0]; return { roleColumnValue: role, depth: row.depth, threadType: assertThreadType(row.type), parentThreadID: row.parent_thread_id ? row.parent_thread_id.toString() : null, hasContainingThreadID: row.containing_thread_id !== null, rolePermissions: JSON.parse(row.permissions), }; } else { const query = SQL` SELECT t.type, t.depth, t.parent_thread_id, t.containing_thread_id, r.permissions, r.id FROM threads t INNER JOIN roles r ON r.thread = t.id AND r.special_role = ${specialRoles.DEFAULT_ROLE} WHERE t.id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('internal_error'); } const row = result[0]; return { roleColumnValue: row.id.toString(), depth: row.depth, threadType: assertThreadType(row.type), parentThreadID: row.parent_thread_id ? row.parent_thread_id.toString() : null, hasContainingThreadID: row.containing_thread_id !== null, rolePermissions: JSON.parse(row.permissions), }; } } type ChangedAncestor = { +threadID: string, +depth: number, +changesByUser: Map, }; type AncestorChanges = { +userIsMember: boolean, +permissionsForChildren: ?ThreadPermissionsBlob, }; async function updateDescendantPermissions( initialChangedAncestor: ChangedAncestor, ): Promise { const membershipRows: Array = []; const relationshipChangeset = new RelationshipChangeset(); const initialDescendants = await fetchDescendantsForUpdate([ initialChangedAncestor, ]); const depthQueue = new DepthQueue( getDescendantDepth, getDescendantKey, mergeDescendants, ); depthQueue.addInfos(initialDescendants); let descendants; while ((descendants = depthQueue.getNextDepth())) { const descendantsAsAncestors = []; for (const descendant of descendants) { const { threadID, threadType, depth, users } = descendant; const existingMembers = [...users.entries()]; const existingMemberIDs = existingMembers .filter(([, { curRole }]) => curRole) .map(([userID]) => userID); - if (threadID !== genesis.id) { + if (threadID !== genesis().id) { relationshipChangeset.setAllRelationshipsExist(existingMemberIDs); } const usersForNextLayer = new Map(); for (const [userID, user] of users) { const { curRolePermissions, curPermissionsFromParent, curMemberOfContainingThread, nextMemberOfContainingThread, nextPermissionsFromParent, potentiallyNeedsUpdate, } = user; const existingMembership = !!user.curRole; const curRole = user.curRole ?? '-1'; const curPermissions = user.curPermissions ?? null; const curPermissionsForChildren = user.curPermissionsForChildren ?? null; if (!potentiallyNeedsUpdate) { continue; } const permissionsFromParent = nextPermissionsFromParent === undefined ? curPermissionsFromParent : nextPermissionsFromParent; const memberOfContainingThread = nextMemberOfContainingThread === undefined ? curMemberOfContainingThread : nextMemberOfContainingThread; const targetRole = memberOfContainingThread ? curRole : '-1'; const rolePermissions = memberOfContainingThread ? curRolePermissions : null; const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, threadType, ); const permissionsForChildren = makePermissionsForChildrenBlob(permissions); const newRole = getRoleForPermissions(targetRole, permissions); const userLostMembership = Number(curRole) > 0 && Number(newRole) <= 0; if (_isEqual(permissions)(curPermissions) && curRole === newRole) { // This thread and all of its descendants need no updates for this // user, since the corresponding memberships row is unchanged by this // operation continue; } if (permissions) { membershipRows.push({ operation: 'save', intent: 'none', userID, threadID, userNeedsFullThreadDetails: false, permissions, permissionsForChildren, role: newRole, oldRole: curRole, }); } else { membershipRows.push({ operation: 'delete', intent: 'none', userID, threadID, oldRole: curRole, }); } - if (permissions && !existingMembership && threadID !== genesis.id) { + if (permissions && !existingMembership && threadID !== genesis().id) { // If there was no membership row before, and we are creating one, // we'll need to make sure the new member has a relationship row with // each existing member. We expect that whoever called us already // generated memberships row for the new members, will will lead // saveMemberships to generate relationships rows between those new // users. relationshipChangeset.setRelationshipsNeeded( userID, existingMemberIDs, ); } if ( userLostMembership || !_isEqual(permissionsForChildren)(curPermissionsForChildren) ) { usersForNextLayer.set(userID, { userIsMember: Number(newRole) > 0, permissionsForChildren, }); } } if (usersForNextLayer.size > 0) { descendantsAsAncestors.push({ threadID, depth, changesByUser: usersForNextLayer, }); } } const nextDescendants = await fetchDescendantsForUpdate( descendantsAsAncestors, ); depthQueue.addInfos(nextDescendants); } return { membershipRows, relationshipChangeset }; } type DescendantUserInfo = Partial<{ curRole?: string, curRolePermissions?: ?ThreadRolePermissionsBlob, curPermissions?: ?ThreadPermissionsBlob, curPermissionsForChildren?: ?ThreadPermissionsBlob, curPermissionsFromParent?: ?ThreadPermissionsBlob, curMemberOfContainingThread?: boolean, nextPermissionsFromParent?: ?ThreadPermissionsBlob, nextMemberOfContainingThread?: boolean, potentiallyNeedsUpdate?: boolean, }>; type DescendantInfo = { +threadID: string, +parentThreadID: string, +containingThreadID: string, +threadType: ThreadType, +depth: number, +users: Map, }; const fetchDescendantsBatchSize = 10; async function fetchDescendantsForUpdate( ancestors: $ReadOnlyArray, ): Promise { const threadIDs = ancestors.map(ancestor => ancestor.threadID); const rows: Array<{ +id: number, +user: number, +type: number, +depth: number, +parent_thread_id: number, +containing_thread_id: number, +role_permissions: string, +permissions: string, +permissions_for_children: string, +role: number, +permissions_from_parent: string | null, +containing_role: ?number, }> = []; while (threadIDs.length > 0) { const batch = threadIDs.splice(0, fetchDescendantsBatchSize); const query = SQL` SELECT t.id, m.user, t.type, t.depth, t.parent_thread_id, t.containing_thread_id, r.permissions AS role_permissions, m.permissions, m.permissions_for_children, m.role, pm.permissions_for_children AS permissions_from_parent, cm.role AS containing_role FROM threads t INNER JOIN memberships m ON m.thread = t.id LEFT JOIN memberships pm ON pm.thread = t.parent_thread_id AND pm.user = m.user LEFT JOIN memberships cm ON cm.thread = t.containing_thread_id AND cm.user = m.user LEFT JOIN roles r ON r.id = m.role WHERE t.parent_thread_id IN (${batch}) OR t.containing_thread_id IN (${batch}) `; const [results] = await dbQuery(query); pushAll(rows, results); } const descendantThreadInfos: Map = new Map(); for (const row of rows) { const descendantThreadID = row.id.toString(); if (!descendantThreadInfos.has(descendantThreadID)) { descendantThreadInfos.set(descendantThreadID, { threadID: descendantThreadID, parentThreadID: row.parent_thread_id.toString(), containingThreadID: row.containing_thread_id.toString(), threadType: assertThreadType(row.type), depth: row.depth, users: new Map(), }); } const descendantThreadInfo = descendantThreadInfos.get(descendantThreadID); invariant( descendantThreadInfo, `value should exist for key ${descendantThreadID}`, ); const userID = row.user.toString(); descendantThreadInfo.users.set(userID, { curRole: row.role.toString(), curRolePermissions: JSON.parse(row.role_permissions), curPermissions: JSON.parse(row.permissions), curPermissionsForChildren: JSON.parse(row.permissions_for_children), curPermissionsFromParent: row.permissions_from_parent ? JSON.parse(row.permissions_from_parent) : null, curMemberOfContainingThread: !!row.containing_role && row.containing_role > 0, }); } for (const ancestor of ancestors) { const { threadID, changesByUser } = ancestor; for (const [userID, changes] of changesByUser) { for (const descendantThreadInfo of descendantThreadInfos.values()) { const { users, parentThreadID, containingThreadID } = descendantThreadInfo; if (threadID !== parentThreadID && threadID !== containingThreadID) { continue; } let user: ?DescendantUserInfo = users.get(userID); if (!user) { user = ({}: DescendantUserInfo); users.set(userID, user); } if (threadID === parentThreadID) { user.nextPermissionsFromParent = changes.permissionsForChildren; user.potentiallyNeedsUpdate = true; } if (threadID === containingThreadID) { user.nextMemberOfContainingThread = changes.userIsMember; if (!user.nextMemberOfContainingThread) { user.potentiallyNeedsUpdate = true; } } } } } return [...descendantThreadInfos.values()]; } function getDescendantDepth(descendant: DescendantInfo): number { return descendant.depth; } function getDescendantKey(descendant: DescendantInfo): string { return descendant.threadID; } function mergeDescendants( a: DescendantInfo, b: DescendantInfo, ): DescendantInfo { const { users: usersA, ...restA } = a; const { users: usersB, ...restB } = b; if (!_isEqual(restA)(restB)) { console.warn( `inconsistent descendantInfos ${JSON.stringify(restA)}, ` + JSON.stringify(restB), ); throw new ServerError('internal_error'); } const newUsers = new Map(usersA); for (const [userID, userFromB] of usersB) { const userFromA = newUsers.get(userID); if (!userFromA) { newUsers.set(userID, userFromB); } else { newUsers.set(userID, { ...userFromA, ...userFromB }); } } return { ...a, users: newUsers }; } type RecalculatePermissionsMemberInfo = { role?: ?string, permissions?: ?ThreadPermissionsBlob, permissionsForChildren?: ?ThreadPermissionsBlob, rolePermissions?: ?ThreadRolePermissionsBlob, memberOfContainingThread?: boolean, permissionsFromParent?: ?ThreadPermissionsBlob, }; async function recalculateThreadPermissions( threadID: string, ): Promise { const threadQuery = SQL` SELECT type, depth, parent_thread_id, containing_thread_id FROM threads WHERE id = ${threadID} `; const membershipQuery = SQL` SELECT m.user, m.role, m.permissions, m.permissions_for_children, r.permissions AS role_permissions, cm.role AS containing_role FROM threads t INNER JOIN memberships m ON m.thread = t.id LEFT JOIN roles r ON r.id = m.role LEFT JOIN memberships cm ON cm.user = m.user AND cm.thread = t.containing_thread_id WHERE t.id = ${threadID} `; const parentMembershipQuery = SQL` SELECT pm.user, pm.permissions_for_children AS permissions_from_parent FROM threads t INNER JOIN memberships pm ON pm.thread = t.parent_thread_id WHERE t.id = ${threadID} `; const [[threadResults], [membershipResults], [parentMembershipResults]] = await Promise.all([ dbQuery(threadQuery), dbQuery(membershipQuery), dbQuery(parentMembershipQuery), ]); if (threadResults.length !== 1) { throw new ServerError('internal_error'); } const [threadResult] = threadResults; const threadType = assertThreadType(threadResult.type); const depth = threadResult.depth; const hasContainingThreadID = threadResult.containing_thread_id !== null; const parentThreadID = threadResult.parent_thread_id?.toString(); const membershipInfo: Map = new Map(); for (const row of membershipResults) { const userID = row.user.toString(); membershipInfo.set(userID, { role: row.role.toString(), permissions: JSON.parse(row.permissions), permissionsForChildren: JSON.parse(row.permissions_for_children), rolePermissions: JSON.parse(row.role_permissions), memberOfContainingThread: !!( row.containing_role && row.containing_role > 0 ), }); } for (const row of parentMembershipResults) { const userID = row.user.toString(); const permissionsFromParent = JSON.parse(row.permissions_from_parent); const membership = membershipInfo.get(userID); if (membership) { membership.permissionsFromParent = permissionsFromParent; } else { membershipInfo.set(userID, { permissionsFromParent: permissionsFromParent, }); } } const relationshipChangeset = new RelationshipChangeset(); const existingMemberIDs = membershipResults.map(row => row.user.toString()); - if (threadID !== genesis.id) { + if (threadID !== genesis().id) { relationshipChangeset.setAllRelationshipsExist(existingMemberIDs); } const parentMemberIDs = parentMembershipResults.map(row => row.user.toString(), ); - if (parentThreadID && parentThreadID !== genesis.id) { + if (parentThreadID && parentThreadID !== genesis().id) { relationshipChangeset.setAllRelationshipsExist(parentMemberIDs); } const membershipRows: Array = []; const toUpdateDescendants = new Map(); for (const [userID, membership] of membershipInfo) { const { rolePermissions: intendedRolePermissions, permissionsFromParent } = membership; const oldPermissions = membership?.permissions ?? null; const oldPermissionsForChildren = membership?.permissionsForChildren ?? null; const existingMembership = membership.role !== undefined; const oldRole = membership.role ?? '-1'; const memberOfContainingThread = hasContainingThreadID ? !!membership.memberOfContainingThread : true; const targetRole = memberOfContainingThread ? oldRole : '-1'; const rolePermissions = memberOfContainingThread ? intendedRolePermissions : null; const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, threadType, ); const permissionsForChildren = makePermissionsForChildrenBlob(permissions); const newRole = getRoleForPermissions(targetRole, permissions); const userLostMembership = Number(oldRole) > 0 && Number(newRole) <= 0; if (_isEqual(permissions)(oldPermissions) && oldRole === newRole) { // This thread and all of its descendants need no updates for this user, // since the corresponding memberships row is unchanged by this operation continue; } if (permissions) { membershipRows.push({ operation: 'save', intent: 'none', userID, threadID, userNeedsFullThreadDetails: false, permissions, permissionsForChildren, role: newRole, oldRole, }); } else { membershipRows.push({ operation: 'delete', intent: 'none', userID, threadID, oldRole, }); } - if (permissions && !existingMembership && threadID !== genesis.id) { + if (permissions && !existingMembership && threadID !== genesis().id) { // If there was no membership row before, and we are creating one, // we'll need to make sure the new member has a relationship row with // each existing member. We handle guaranteeing that new members have // relationship rows with each other in saveMemberships. relationshipChangeset.setRelationshipsNeeded(userID, existingMemberIDs); } if ( userLostMembership || !_isEqual(permissionsForChildren)(oldPermissionsForChildren) ) { toUpdateDescendants.set(userID, { userIsMember: Number(newRole) > 0, permissionsForChildren, }); } } if (toUpdateDescendants.size > 0) { const { membershipRows: descendantMembershipRows, relationshipChangeset: descendantRelationshipChangeset, } = await updateDescendantPermissions({ threadID, depth, changesByUser: toUpdateDescendants, }); pushAll(membershipRows, descendantMembershipRows); relationshipChangeset.addAll(descendantRelationshipChangeset); } return { membershipRows, relationshipChangeset }; } const defaultSubscriptionString = JSON.stringify({ home: false, pushNotifs: false, }); const joinSubscriptionString = JSON.stringify({ home: true, pushNotifs: true }); const membershipInsertBatchSize = 50; const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; async function saveMemberships({ toSave, updateMembershipsLastMessage, }: { toSave: $ReadOnlyArray, updateMembershipsLastMessage: boolean, }) { if (toSave.length === 0) { return; } const time = Date.now(); const insertRows = []; for (const rowToSave of toSave) { insertRows.push([ rowToSave.userID, rowToSave.threadID, rowToSave.role, time, rowToSave.intent === 'join' ? joinSubscriptionString : defaultSubscriptionString, rowToSave.permissions ? JSON.stringify(rowToSave.permissions) : null, rowToSave.permissionsForChildren ? JSON.stringify(rowToSave.permissionsForChildren) : null, rowToSave.unread ? 1 : 0, 0, ]); } // Logic below will only update an existing membership row's `subscription` // column if the user is either joining or leaving the thread. That means // there's no way to use this function to update a user's subscription without // also making them join or leave the thread. The reason we do this is because // we need to specify a value for `subscription` here, as it's a non-null // column and this is an INSERT, but we don't want to require people to have // to know the current `subscription` when they're just using this function to // update the permissions of an existing membership row. while (insertRows.length > 0) { const batch = insertRows.splice(0, membershipInsertBatchSize); const query = SQL` INSERT INTO memberships (user, thread, role, creation_time, subscription, permissions, permissions_for_children, last_message, last_read_message) VALUES ${batch} ON DUPLICATE KEY UPDATE subscription = IF( (role <= 0 AND VALUE(role) > 0) OR (role > 0 AND VALUE(role) <= 0), VALUE(subscription), subscription ), role = VALUE(role), permissions = VALUE(permissions), permissions_for_children = VALUE(permissions_for_children) `; await dbQuery(query); } if (!updateMembershipsLastMessage) { return; } const joinRows = toSave .filter(row => row.intent === 'join') .map(row => [row.userID, row.threadID, row.unread]); if (joinRows.length === 0) { return; } const joinedUserThreadPairs = joinRows.map(([user, thread]) => [ user, thread, ]); const unreadUserThreadPairs = joinRows .filter(([, , unread]) => !!unread) .map(([user, thread]) => [user, thread]); let lastReadMessageExpression; if (unreadUserThreadPairs.length === 0) { lastReadMessageExpression = SQL` GREATEST(COALESCE(all_users_query.message, 0), COALESCE(last_subthread_message_for_user_query.message, 0)) `; } else { lastReadMessageExpression = SQL` (CASE WHEN ((mm.user, mm.thread) in (${unreadUserThreadPairs})) THEN 0 ELSE GREATEST(COALESCE(all_users_query.message, 0), COALESCE(last_subthread_message_for_user_query.message, 0)) END) `; } // We join two subqueries with the memberships table: // - the first subquery calculates the oldest non-CREATE_SUB_THREAD // message, which is the same for all users // - the second subquery calculates the oldest CREATE_SUB_THREAD messages, // which can be different for each user because of visibility permissions // Then we set the `last_message` column to the greater value of the two. // For `last_read_message` we do the same but only if the user should have // the "unread" status set for this thread. const query = SQL` UPDATE memberships mm LEFT JOIN ( SELECT thread, MAX(id) AS message FROM messages WHERE type != ${messageTypes.CREATE_SUB_THREAD} GROUP BY thread ) all_users_query ON mm.thread = all_users_query.thread LEFT JOIN ( SELECT m.thread, stm.user, MAX(m.id) AS message FROM messages m LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content WHERE JSON_EXTRACT(stm.permissions, ${visibleExtractString}) IS TRUE GROUP BY m.thread, stm.user ) last_subthread_message_for_user_query ON mm.thread = last_subthread_message_for_user_query.thread AND mm.user = last_subthread_message_for_user_query.user SET mm.last_message = GREATEST(COALESCE(all_users_query.message, 0), COALESCE(last_subthread_message_for_user_query.message, 0)), mm.last_read_message = `; query.append(lastReadMessageExpression); query.append(SQL`WHERE (mm.user, mm.thread) IN (${joinedUserThreadPairs});`); await dbQuery(query); } async function deleteMemberships( toDelete: $ReadOnlyArray, ) { if (toDelete.length === 0) { return; } const time = Date.now(); const insertRows = toDelete.map(rowToDelete => [ rowToDelete.userID, rowToDelete.threadID, -1, time, defaultSubscriptionString, null, null, 0, 0, ]); while (insertRows.length > 0) { const batch = insertRows.splice(0, membershipInsertBatchSize); const query = SQL` INSERT INTO memberships (user, thread, role, creation_time, subscription, permissions, permissions_for_children, last_message, last_read_message) VALUES ${batch} ON DUPLICATE KEY UPDATE role = -1, permissions = NULL, permissions_for_children = NULL, subscription = ${defaultSubscriptionString}, last_message = 0, last_read_message = 0 `; await dbQuery(query); } } const emptyCommitMembershipChangesetConfig = Object.freeze({}); // Specify non-empty changedThreadIDs to force updates to be generated for those // threads, presumably for reasons not covered in the changeset. calendarQuery // only needs to be specified if a JOIN_THREAD update will be generated for the // viewer, in which case it's necessary for knowing the set of entries to fetch. type ChangesetCommitResult = { ...CreateUpdatesResult, }; async function commitMembershipChangeset( viewer: Viewer, changeset: MembershipChangeset, { changedThreadIDs = new Set(), calendarQuery, updatesForCurrentSession = 'return', updateMembershipsLastMessage = false, }: { +changedThreadIDs?: Set, +calendarQuery?: ?CalendarQuery, +updatesForCurrentSession?: UpdatesForCurrentSession, +updateMembershipsLastMessage?: boolean, } = emptyCommitMembershipChangesetConfig, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const { membershipRows, relationshipChangeset } = changeset; const membershipRowMap = new Map(); for (const row of membershipRows) { const { userID, threadID } = row; changedThreadIDs.add(threadID); const pairString = `${userID}|${threadID}`; const existing = membershipRowMap.get(pairString); invariant( !existing || existing.intent === 'none' || row.intent === 'none', `multiple intents provided for ${pairString}`, ); if (!existing || existing.intent === 'none') { membershipRowMap.set(pairString, row); } } const toSave = [], toDelete = [], toRescindPushNotifs = []; for (const row of membershipRowMap.values()) { if ( row.operation === 'delete' || (row.operation === 'save' && Number(row.role) <= 0) ) { const { userID, threadID } = row; toRescindPushNotifs.push({ userID, threadID }); } if (row.operation === 'delete') { toDelete.push(row); } else { toSave.push(row); } } const threadsToSavedUsers = new Map>(); for (const row of membershipRowMap.values()) { const { userID, threadID } = row; let savedUsers = threadsToSavedUsers.get(threadID); if (!savedUsers) { savedUsers = []; threadsToSavedUsers.set(threadID, savedUsers); } savedUsers.push(userID); } for (const [threadID, savedUsers] of threadsToSavedUsers) { - if (threadID !== genesis.id) { + if (threadID !== genesis().id) { relationshipChangeset.setAllRelationshipsNeeded(savedUsers); } } const relationshipRows = relationshipChangeset.getRows(); const [updateDatas] = await Promise.all([ updateChangedUndirectedRelationships(relationshipRows), saveMemberships({ toSave, updateMembershipsLastMessage }), deleteMemberships(toDelete), rescindPushNotifsForMemberDeletion(toRescindPushNotifs), ]); const serverThreadInfoFetchResult = await fetchServerThreadInfos({ threadIDs: changedThreadIDs, }); const { threadInfos: serverThreadInfos } = serverThreadInfoFetchResult; const time = Date.now(); for (const changedThreadID of changedThreadIDs) { const serverThreadInfo = serverThreadInfos[changedThreadID]; for (const memberInfo of serverThreadInfo.members) { const pairString = `${memberInfo.id}|${serverThreadInfo.id}`; const membershipRow = membershipRowMap.get(pairString); if (membershipRow) { continue; } updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID: memberInfo.id, time, threadID: changedThreadID, }); } } for (const row of membershipRowMap.values()) { const { userID, threadID } = row; if (row.operation === 'delete' || row.role === '-1') { if (row.oldRole !== '-1') { updateDatas.push({ type: updateTypes.DELETE_THREAD, userID, time, threadID, }); } } else if (row.userNeedsFullThreadDetails) { updateDatas.push({ type: updateTypes.JOIN_THREAD, userID, time, threadID, }); } else { updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID, time, threadID, }); } } const threadInfoFetchResult = rawThreadInfosFromServerThreadInfos( viewer, serverThreadInfoFetchResult, ); const { viewerUpdates, userInfos } = await createUpdates(updateDatas, { viewer, calendarQuery, ...threadInfoFetchResult, updatesForCurrentSession, }); return { userInfos, viewerUpdates, }; } const emptyGetChangesetCommitResultConfig = Object.freeze({}); // When the user tries to create a new thread, it's possible for the client to // fail the creation even if a row gets added to the threads table. This may // occur due to a timeout (on either the client or server side), or due to some // error in the server code following the INSERT operation. Handling the error // scenario is more challenging since it would require detecting which set of // operations failed so we could retry them. As a result, this code is geared at // only handling the timeout scenario. async function getChangesetCommitResultForExistingThread( viewer: Viewer, threadID: string, otherUpdates: $ReadOnlyArray, { calendarQuery, updatesForCurrentSession = 'return', }: { +calendarQuery?: ?CalendarQuery, +updatesForCurrentSession?: UpdatesForCurrentSession, } = emptyGetChangesetCommitResultConfig, ): Promise { for (const update of otherUpdates) { if ( update.type === updateTypes.JOIN_THREAD && update.threadInfo.id === threadID ) { // If the JOIN_THREAD is already there we can expect // the appropriate UPDATE_USERs to be covered as well return { viewerUpdates: otherUpdates, userInfos: {} }; } } const time = Date.now(); const updateDatas: Array = [ { type: updateTypes.JOIN_THREAD, userID: viewer.userID, time, threadID, targetSession: viewer.session, }, ]; // To figure out what UserInfos might be missing, we consider the worst case: // the same client previously attempted to create a thread with a non-friend // they found via search results, but the request timed out. In this scenario // the viewer might never have received the UPDATE_USER that would add that // UserInfo to their UserStore, but the server assumed the client had gotten // it because createUpdates was called with UpdatesForCurrentSession=return. // For completeness here we query for the full list of memberships rows in the // thread. We can't use fetchServerThreadInfos because it skips role=-1 rows const membershipsQuery = SQL` SELECT user FROM memberships WHERE thread = ${threadID} AND user != ${viewer.userID} `; const [results] = await dbQuery(membershipsQuery); for (const row of results) { updateDatas.push({ type: updateTypes.UPDATE_USER, userID: viewer.userID, time, updatedUserID: row.user.toString(), targetSession: viewer.session, }); } const { viewerUpdates, userInfos } = await createUpdates(updateDatas, { viewer, calendarQuery, updatesForCurrentSession, }); return { viewerUpdates: [...otherUpdates, ...viewerUpdates], userInfos }; } const rescindPushNotifsBatchSize = 3; async function rescindPushNotifsForMemberDeletion( toRescindPushNotifs: $ReadOnlyArray<{ +userID: string, +threadID: string }>, ): Promise { const queue = [...toRescindPushNotifs]; while (queue.length > 0) { const batch = queue.splice(0, rescindPushNotifsBatchSize); await Promise.all( batch.map(({ userID, threadID }) => rescindPushNotifs( SQL`n.thread = ${threadID} AND n.user = ${userID}`, SQL`IF(m.thread = ${threadID}, NULL, m.thread)`, ), ), ); } } // Deprecated - use updateRolesAndPermissionsForAllThreads instead async function DEPRECATED_recalculateAllThreadPermissions() { const getAllThreads = SQL`SELECT id FROM threads`; const [result] = await dbQuery(getAllThreads); // We handle each thread one-by-one to avoid a situation where a permission // calculation for a child thread, done during a call to // recalculateThreadPermissions for the parent thread, can be incorrectly // overriden by a call to recalculateThreadPermissions for the child thread. // If the changeset resulting from the parent call isn't committed before the // calculation is done for the child, the calculation done for the child can // be incorrect. const viewer = createScriptViewer(bots.commbot.userID); for (const row of result) { const threadID = row.id.toString(); const changeset = await recalculateThreadPermissions(threadID); await commitMembershipChangeset(viewer, changeset); } } async function updateRolesAndPermissionsForAllThreads() { const batchSize = 10; const fetchThreads = SQL`SELECT id, type, depth FROM threads`; const [result] = await dbQuery(fetchThreads); const allThreads = result.map(row => { return { id: row.id.toString(), type: assertThreadType(row.type), depth: row.depth, }; }); const viewer = createScriptViewer(bots.commbot.userID); const maxDepth = Math.max(...allThreads.map(row => row.depth)); for (let depth = 0; depth <= maxDepth; depth++) { const threads = allThreads.filter(row => row.depth === depth); console.log(`recalculating permissions for threads with depth ${depth}`); while (threads.length > 0) { const batch = threads.splice(0, batchSize); const membershipRows: Array = []; const relationshipChangeset = new RelationshipChangeset(); await Promise.all( batch.map(async thread => { console.log(`updating roles for ${thread.id}`); await updateRoles(viewer, thread.id, thread.type); console.log(`recalculating permissions for ${thread.id}`); const { membershipRows: threadMembershipRows, relationshipChangeset: threadRelationshipChangeset, } = await recalculateThreadPermissions(thread.id); membershipRows.push(...threadMembershipRows); relationshipChangeset.addAll(threadRelationshipChangeset); }), ); console.log(`committing batch ${JSON.stringify(batch)}`); await commitMembershipChangeset(viewer, { membershipRows, relationshipChangeset, }); } } } export { changeRole, recalculateThreadPermissions, getChangesetCommitResultForExistingThread, saveMemberships, commitMembershipChangeset, DEPRECATED_recalculateAllThreadPermissions, updateRolesAndPermissionsForAllThreads, }; diff --git a/lib/actions/thread-actions.js b/lib/actions/thread-actions.js index ee30973a3..1cf4eaea1 100644 --- a/lib/actions/thread-actions.js +++ b/lib/actions/thread-actions.js @@ -1,393 +1,393 @@ // @flow import invariant from 'invariant'; import genesis from '../facts/genesis.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import type { CallKeyserverEndpoint } from '../keyserver-conn/keyserver-conn-types.js'; import type { ChangeThreadSettingsPayload, LeaveThreadPayload, UpdateThreadRequest, ClientNewThreadRequest, NewThreadResult, ClientThreadJoinRequest, ThreadJoinPayload, ThreadFetchMediaRequest, ThreadFetchMediaResult, RoleModificationRequest, RoleModificationPayload, RoleDeletionRequest, RoleDeletionPayload, } from '../types/thread-types.js'; import { useKeyserverCall } from '../utils/keyserver-call.js'; import { values } from '../utils/objects.js'; export type DeleteThreadInput = { +threadID: string, }; const deleteThreadActionTypes = Object.freeze({ started: 'DELETE_THREAD_STARTED', success: 'DELETE_THREAD_SUCCESS', failed: 'DELETE_THREAD_FAILED', }); const deleteThreadEndpointOptions = { timeout: 60000, }; const deleteThread = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: DeleteThreadInput) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'delete_thread', requests, deleteThreadEndpointOptions, ); const response = responses[keyserverID]; return { updatesResult: response.updatesResult, }; }; function useDeleteThread(): ( input: DeleteThreadInput, ) => Promise { return useKeyserverCall(deleteThread); } const changeThreadSettingsActionTypes = Object.freeze({ started: 'CHANGE_THREAD_SETTINGS_STARTED', success: 'CHANGE_THREAD_SETTINGS_SUCCESS', failed: 'CHANGE_THREAD_SETTINGS_FAILED', }); const changeThreadSettingsEndpointOptions = { timeout: 60000, }; const changeThreadSettings = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: UpdateThreadRequest) => Promise) => async input => { invariant( Object.keys(input.changes).length > 0, 'No changes provided to changeThreadSettings!', ); const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'update_thread', requests, changeThreadSettingsEndpointOptions, ); const response = responses[keyserverID]; return { threadID: input.threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; }; function useChangeThreadSettings(): ( input: UpdateThreadRequest, ) => Promise { return useKeyserverCall(changeThreadSettings); } export type RemoveUsersFromThreadInput = { +threadID: string, +memberIDs: $ReadOnlyArray, }; const removeUsersFromThreadActionTypes = Object.freeze({ started: 'REMOVE_USERS_FROM_THREAD_STARTED', success: 'REMOVE_USERS_FROM_THREAD_SUCCESS', failed: 'REMOVE_USERS_FROM_THREAD_FAILED', }); const removeMembersFromThreadEndpointOptions = { timeout: 60000, }; const removeUsersFromThread = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: RemoveUsersFromThreadInput, ) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'remove_members', requests, removeMembersFromThreadEndpointOptions, ); const response = responses[keyserverID]; return { threadID: input.threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; }; function useRemoveUsersFromThread(): ( input: RemoveUsersFromThreadInput, ) => Promise { return useKeyserverCall(removeUsersFromThread); } export type ChangeThreadMemberRolesInput = { +threadID: string, +memberIDs: $ReadOnlyArray, +newRole: string, }; const changeThreadMemberRolesActionTypes = Object.freeze({ started: 'CHANGE_THREAD_MEMBER_ROLES_STARTED', success: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS', failed: 'CHANGE_THREAD_MEMBER_ROLES_FAILED', }); const changeThreadMemberRoleEndpointOptions = { timeout: 60000, }; const changeThreadMemberRoles = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: ChangeThreadMemberRolesInput, ) => Promise) => async input => { const { threadID, memberIDs, newRole } = input; const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: { threadID, memberIDs, role: newRole, }, }; const responses = await callKeyserverEndpoint( 'update_role', requests, changeThreadMemberRoleEndpointOptions, ); const response = responses[keyserverID]; return { threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; }; function useChangeThreadMemberRoles(): ( input: ChangeThreadMemberRolesInput, ) => Promise { return useKeyserverCall(changeThreadMemberRoles); } const newThreadActionTypes = Object.freeze({ started: 'NEW_THREAD_STARTED', success: 'NEW_THREAD_SUCCESS', failed: 'NEW_THREAD_FAILED', }); const newThread = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: ClientNewThreadRequest) => Promise) => async input => { - const parentThreadID = input.parentThreadID ?? genesis.id; + const parentThreadID = input.parentThreadID ?? genesis().id; const keyserverID = extractKeyserverIDFromID(parentThreadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint('create_thread', requests); const response = responses[keyserverID]; return { newThreadID: response.newThreadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, userInfos: response.userInfos, }; }; function useNewThread(): ( input: ClientNewThreadRequest, ) => Promise { return useKeyserverCall(newThread); } const joinThreadActionTypes = Object.freeze({ started: 'JOIN_THREAD_STARTED', success: 'JOIN_THREAD_SUCCESS', failed: 'JOIN_THREAD_FAILED', }); const joinThread = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: ClientThreadJoinRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint('join_thread', requests); const response = responses[keyserverID]; const userInfos = values(response.userInfos); return { updatesResult: response.updatesResult, rawMessageInfos: response.rawMessageInfos, truncationStatuses: response.truncationStatuses, userInfos, }; }; function useJoinThread(): ( input: ClientThreadJoinRequest, ) => Promise { return useKeyserverCall(joinThread); } export type LeaveThreadInput = { +threadID: string, }; const leaveThreadActionTypes = Object.freeze({ started: 'LEAVE_THREAD_STARTED', success: 'LEAVE_THREAD_SUCCESS', failed: 'LEAVE_THREAD_FAILED', }); const leaveThread = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: LeaveThreadInput) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint('leave_thread', requests); const response = responses[keyserverID]; return { updatesResult: response.updatesResult, }; }; function useLeaveThread(): ( input: LeaveThreadInput, ) => Promise { return useKeyserverCall(leaveThread); } const fetchThreadMedia = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: ThreadFetchMediaRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'fetch_thread_media', requests, ); const response = responses[keyserverID]; return { media: response.media }; }; function useFetchThreadMedia(): ( input: ThreadFetchMediaRequest, ) => Promise { return useKeyserverCall(fetchThreadMedia); } const modifyCommunityRoleActionTypes = Object.freeze({ started: 'MODIFY_COMMUNITY_ROLE_STARTED', success: 'MODIFY_COMMUNITY_ROLE_SUCCESS', failed: 'MODIFY_COMMUNITY_ROLE_FAILED', }); const modifyCommunityRole = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: RoleModificationRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.community); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'modify_community_role', requests, ); const response = responses[keyserverID]; return { threadInfo: response.threadInfo, updatesResult: response.updatesResult, }; }; function useModifyCommunityRole(): ( input: RoleModificationRequest, ) => Promise { return useKeyserverCall(modifyCommunityRole); } const deleteCommunityRoleActionTypes = Object.freeze({ started: 'DELETE_COMMUNITY_ROLE_STARTED', success: 'DELETE_COMMUNITY_ROLE_SUCCESS', failed: 'DELETE_COMMUNITY_ROLE_FAILED', }); const deleteCommunityRole = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: RoleDeletionRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.community); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'delete_community_role', requests, ); const response = responses[keyserverID]; return { threadInfo: response.threadInfo, updatesResult: response.updatesResult, }; }; function useDeleteCommunityRole(): ( input: RoleDeletionRequest, ) => Promise { return useKeyserverCall(deleteCommunityRole); } export { deleteThreadActionTypes, useDeleteThread, changeThreadSettingsActionTypes, useChangeThreadSettings, removeUsersFromThreadActionTypes, useRemoveUsersFromThread, changeThreadMemberRolesActionTypes, useChangeThreadMemberRoles, newThreadActionTypes, useNewThread, joinThreadActionTypes, useJoinThread, leaveThreadActionTypes, useLeaveThread, useFetchThreadMedia, modifyCommunityRoleActionTypes, useModifyCommunityRole, deleteCommunityRoleActionTypes, useDeleteCommunityRole, }; diff --git a/lib/components/chat-mention-provider.react.js b/lib/components/chat-mention-provider.react.js index 808db957c..21f666a29 100644 --- a/lib/components/chat-mention-provider.react.js +++ b/lib/components/chat-mention-provider.react.js @@ -1,291 +1,291 @@ // @flow import * as React from 'react'; import genesis from '../facts/genesis.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import SentencePrefixSearchIndex from '../shared/sentence-prefix-search-index.js'; import { threadIsPending } from '../shared/thread-utils.js'; import type { ResolvedThreadInfo, ThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { ChatMentionCandidate, ChatMentionCandidatesObj, } from '../types/thread-types.js'; import { useResolvedThreadInfosObj } from '../utils/entity-helpers.js'; import { getNameForThreadEntity } from '../utils/entity-text.js'; import { useSelector } from '../utils/redux-utils.js'; type Props = { +children: React.Node, }; export type ChatMentionContextType = { +getChatMentionSearchIndex: ( threadInfo: ThreadInfo, ) => ?SentencePrefixSearchIndex, +getCommunityThreadIDForGenesisThreads: (threadInfo: ThreadInfo) => string, +chatMentionCandidatesObj: ChatMentionCandidatesObj, }; const emptySearchIndex = new SentencePrefixSearchIndex(); const ChatMentionContext: React.Context = React.createContext({ getChatMentionSearchIndex: () => emptySearchIndex, getCommunityThreadIDForGenesisThreads: () => '', chatMentionCandidatesObj: {}, }); function ChatMentionContextProvider(props: Props): React.Node { const { children } = props; const { chatMentionCandidatesObj, getCommunityThreadIDForGenesisThreads } = useChatMentionCandidatesObjAndUtils(); const searchIndices = useChatMentionSearchIndex(chatMentionCandidatesObj); const getChatMentionSearchIndex = React.useCallback( (threadInfo: ThreadInfo) => { - if (threadInfo.community === genesis.id) { + if (threadInfo.community === genesis().id) { const communityThreadID = getCommunityThreadIDForGenesisThreads(threadInfo); return searchIndices[communityThreadID]; } return searchIndices[threadInfo.community ?? threadInfo.id]; }, [getCommunityThreadIDForGenesisThreads, searchIndices], ); const value = React.useMemo( () => ({ getChatMentionSearchIndex, getCommunityThreadIDForGenesisThreads, chatMentionCandidatesObj, }), [ getChatMentionSearchIndex, getCommunityThreadIDForGenesisThreads, chatMentionCandidatesObj, ], ); return ( {children} ); } function getChatMentionCandidates( threadInfos: { +[id: string]: ThreadInfo }, resolvedThreadInfos: { +[id: string]: ResolvedThreadInfo, }, ): { chatMentionCandidatesObj: ChatMentionCandidatesObj, communityThreadIDForGenesisThreads: { +[id: string]: string }, } { const result: { [string]: { [string]: ChatMentionCandidate, }, } = {}; const visitedGenesisThreads = new Set(); const communityThreadIDForGenesisThreads: { [string]: string } = {}; for (const currentThreadID in resolvedThreadInfos) { const currentResolvedThreadInfo = resolvedThreadInfos[currentThreadID]; const { community: currentThreadCommunity } = currentResolvedThreadInfo; if (!currentThreadCommunity) { if (!result[currentThreadID]) { result[currentThreadID] = { [currentThreadID]: { threadInfo: currentResolvedThreadInfo, rawChatName: threadInfos[currentThreadID].uiName, }, }; } continue; } if (!result[currentThreadCommunity]) { result[currentThreadCommunity] = {}; result[currentThreadCommunity][currentThreadCommunity] = { threadInfo: resolvedThreadInfos[currentThreadCommunity], rawChatName: threadInfos[currentThreadCommunity].uiName, }; } // Handle GENESIS community case: mentioning inside GENESIS should only // show chats and threads inside the top level that is below GENESIS. if ( resolvedThreadInfos[currentThreadCommunity].type === threadTypes.GENESIS ) { if (visitedGenesisThreads.has(currentThreadID)) { continue; } const threadTraversePath = [currentResolvedThreadInfo]; visitedGenesisThreads.add(currentThreadID); let currentlySelectedThreadID = currentResolvedThreadInfo.parentThreadID; while (currentlySelectedThreadID) { const currentlySelectedThreadInfo = resolvedThreadInfos[currentlySelectedThreadID]; if ( visitedGenesisThreads.has(currentlySelectedThreadID) || !currentlySelectedThreadInfo || currentlySelectedThreadInfo.type === threadTypes.GENESIS ) { break; } threadTraversePath.push(currentlySelectedThreadInfo); visitedGenesisThreads.add(currentlySelectedThreadID); currentlySelectedThreadID = currentlySelectedThreadInfo.parentThreadID; } const lastThreadInTraversePath = threadTraversePath[threadTraversePath.length - 1]; let lastThreadInTraversePathParentID; if (lastThreadInTraversePath.parentThreadID) { lastThreadInTraversePathParentID = resolvedThreadInfos[ lastThreadInTraversePath.parentThreadID ] ? lastThreadInTraversePath.parentThreadID : lastThreadInTraversePath.id; } else { lastThreadInTraversePathParentID = lastThreadInTraversePath.id; } if ( resolvedThreadInfos[lastThreadInTraversePathParentID].type === threadTypes.GENESIS ) { if (!result[lastThreadInTraversePath.id]) { result[lastThreadInTraversePath.id] = {}; } for (const threadInfo of threadTraversePath) { result[lastThreadInTraversePath.id][threadInfo.id] = { threadInfo, rawChatName: threadInfos[threadInfo.id].uiName, }; communityThreadIDForGenesisThreads[threadInfo.id] = lastThreadInTraversePath.id; } if ( lastThreadInTraversePath.type !== threadTypes.PERSONAL && lastThreadInTraversePath.type !== threadTypes.PRIVATE ) { - result[genesis.id][lastThreadInTraversePath.id] = { + result[genesis().id][lastThreadInTraversePath.id] = { threadInfo: lastThreadInTraversePath, rawChatName: threadInfos[lastThreadInTraversePath.id].uiName, }; } } else { if ( !communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID] ) { result[lastThreadInTraversePathParentID] = {}; communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID] = lastThreadInTraversePathParentID; } const lastThreadInTraversePathParentCommunityThreadID = communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID]; for (const threadInfo of threadTraversePath) { result[lastThreadInTraversePathParentCommunityThreadID][ threadInfo.id ] = { threadInfo, rawChatName: threadInfos[threadInfo.id].uiName, }; communityThreadIDForGenesisThreads[threadInfo.id] = lastThreadInTraversePathParentCommunityThreadID; } } continue; } result[currentThreadCommunity][currentThreadID] = { threadInfo: currentResolvedThreadInfo, rawChatName: threadInfos[currentThreadID].uiName, }; } return { chatMentionCandidatesObj: result, communityThreadIDForGenesisThreads, }; } // Without allAtOnce, useChatMentionCandidatesObjAndUtils is very expensive. // useResolvedThreadInfosObj would trigger its recalculation for each ENS name // as it streams in, but we would prefer to trigger its recaculation just once // for every update of the underlying Redux data. const useResolvedThreadInfosObjOptions = { allAtOnce: true }; function useChatMentionCandidatesObjAndUtils(): { chatMentionCandidatesObj: ChatMentionCandidatesObj, getCommunityThreadIDForGenesisThreads: (threadInfo: ThreadInfo) => string, } { const threadInfos = useSelector(threadInfoSelector); const resolvedThreadInfos = useResolvedThreadInfosObj( threadInfos, useResolvedThreadInfosObjOptions, ); const { chatMentionCandidatesObj, communityThreadIDForGenesisThreads } = React.useMemo( () => getChatMentionCandidates(threadInfos, resolvedThreadInfos), [threadInfos, resolvedThreadInfos], ); const getCommunityThreadIDForGenesisThreads = React.useCallback( (threadInfo: ThreadInfo): string => { const threadID = threadIsPending(threadInfo.id) && threadInfo.containingThreadID ? threadInfo.containingThreadID : threadInfo.id; return communityThreadIDForGenesisThreads[threadID]; }, [communityThreadIDForGenesisThreads], ); return { chatMentionCandidatesObj, getCommunityThreadIDForGenesisThreads, }; } function useChatMentionSearchIndex( chatMentionCandidatesObj: ChatMentionCandidatesObj, ): { +[id: string]: SentencePrefixSearchIndex, } { return React.useMemo(() => { const result: { [string]: SentencePrefixSearchIndex } = {}; for (const communityThreadID in chatMentionCandidatesObj) { const searchIndex = new SentencePrefixSearchIndex(); const searchIndexEntries = []; for (const threadID in chatMentionCandidatesObj[communityThreadID]) { searchIndexEntries.push({ id: threadID, uiName: chatMentionCandidatesObj[communityThreadID][threadID].threadInfo .uiName, rawChatName: chatMentionCandidatesObj[communityThreadID][threadID].rawChatName, }); } // Sort the keys so that the order of the search result is consistent searchIndexEntries.sort(({ uiName: uiNameA }, { uiName: uiNameB }) => uiNameA.localeCompare(uiNameB), ); for (const { id, uiName, rawChatName } of searchIndexEntries) { const names = [uiName]; if (rawChatName) { typeof rawChatName === 'string' ? names.push(rawChatName) : names.push(getNameForThreadEntity(rawChatName)); } searchIndex.addEntry(id, names.join(' ')); } result[communityThreadID] = searchIndex; } return result; }, [chatMentionCandidatesObj]); } export { ChatMentionContextProvider, ChatMentionContext }; diff --git a/lib/facts/genesis.js b/lib/facts/genesis.js index 6bf2b749b..4da78bcbc 100644 --- a/lib/facts/genesis.js +++ b/lib/facts/genesis.js @@ -1,23 +1,25 @@ // @flow +import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; + type Genesis = { +id: string, +name: string, +description: string, +introMessages: $ReadOnlyArray, }; -const genesis: Genesis = { - id: process.env['KEYSERVER'] ? '1' : '256|1', +const genesis: () => Genesis = () => ({ + id: process.env['KEYSERVER'] ? '1' : `${authoritativeKeyserverID()}|1`, name: 'GENESIS', description: 'This is the first community on Comm. In the future it will be possible to create chats outside of a community, but for now all of these chats get set with GENESIS as their parent. GENESIS is hosted on Ashoat’s keyserver.', introMessages: [ 'welcome to Genesis!', 'for now, Genesis is the only community on Comm, and is the parent of all new chats', 'this is meant to be temporary. we’re working on support for chats that can exist outside of any community, as well as support for user-hosted communities', 'to learn more about our roadmap and how Genesis fits in, check out [this document](https://www.notion.so/Comm-Genesis-1059f131fb354250abd1966894b15951)', ], -}; +}); export default genesis; diff --git a/lib/hooks/chat-mention-hooks.js b/lib/hooks/chat-mention-hooks.js index be423228b..fdcfa4e25 100644 --- a/lib/hooks/chat-mention-hooks.js +++ b/lib/hooks/chat-mention-hooks.js @@ -1,50 +1,50 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ChatMentionContext, type ChatMentionContextType, } from '../components/chat-mention-provider.react.js'; import genesis from '../facts/genesis.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { ChatMentionCandidates } from '../types/thread-types.js'; function useChatMentionContext(): ChatMentionContextType { const context = React.useContext(ChatMentionContext); invariant(context, 'ChatMentionContext not found'); return context; } function useThreadChatMentionCandidates( threadInfo: ThreadInfo, ): ChatMentionCandidates { const { getCommunityThreadIDForGenesisThreads, chatMentionCandidatesObj } = useChatMentionContext(); const communityThreadIDForGenesisThreads = getCommunityThreadIDForGenesisThreads(threadInfo); return React.useMemo(() => { const communityID = - threadInfo.community === genesis.id + threadInfo.community === genesis().id ? communityThreadIDForGenesisThreads : threadInfo.community ?? threadInfo.id; const allChatsWithinCommunity = chatMentionCandidatesObj[communityID]; if (!allChatsWithinCommunity) { return {}; } const { [threadInfo.id]: _, ...result } = allChatsWithinCommunity; return result; }, [ chatMentionCandidatesObj, communityThreadIDForGenesisThreads, threadInfo.community, threadInfo.id, ]); } export { useThreadChatMentionCandidates, useChatMentionContext }; diff --git a/lib/selectors/thread-selectors.js b/lib/selectors/thread-selectors.js index 62bc023ba..f7af8b19c 100644 --- a/lib/selectors/thread-selectors.js +++ b/lib/selectors/thread-selectors.js @@ -1,560 +1,560 @@ // @flow import _compact from 'lodash/fp/compact.js'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _map from 'lodash/fp/map.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _orderBy from 'lodash/fp/orderBy.js'; import _some from 'lodash/fp/some.js'; import _sortBy from 'lodash/fp/sortBy.js'; import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { filteredThreadIDsSelector, includeDeletedSelector, } from './calendar-filter-selectors.js'; import { relativeMemberInfoSelectorForMembersOfThread } from './user-selectors.js'; import genesis from '../facts/genesis.js'; import { getAvatarForThread, getRandomDefaultEmojiAvatar, } from '../shared/avatar-utils.js'; import { createEntryInfo } from '../shared/entry-utils.js'; import { getMostRecentNonLocalMessageID } from '../shared/message-utils.js'; import { threadInHomeChatList, threadInBackgroundChatList, threadInFilterList, threadInfoFromRawThreadInfo, threadHasPermission, threadInChatList, threadHasAdminRole, roleIsAdminRole, threadIsPending, getPendingThreadID, } from '../shared/thread-utils.js'; import type { ClientAvatar, ClientEmojiAvatar } from '../types/avatar-types'; import type { EntryInfo } from '../types/entry-types.js'; import type { MessageStore, RawMessageInfo } from '../types/message-types.js'; import type { RelativeMemberInfo, ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { threadTypes, threadTypeIsCommunityRoot, type ThreadType, } from '../types/thread-types-enum.js'; import type { SidebarInfo, MixedRawThreadInfos, RawThreadInfos, } from '../types/thread-types.js'; import { dateString, dateFromString } from '../utils/date-utils.js'; import { values } from '../utils/objects.js'; 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, ); const communityThreadSelector: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadTypeIsCommunityRoot(threadInfo.type)) { continue; } result.push(threadInfo); } return result; }, ); const canBeOnScreenThreadInfos: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadInFilterList(threadInfo)) { continue; } result.push(threadInfo); } return result; }, ); const onScreenThreadInfos: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( filteredThreadIDsSelector, canBeOnScreenThreadInfos, ( inputThreadIDs: ?$ReadOnlySet, threadInfos: $ReadOnlyArray, ): $ReadOnlyArray => { const threadIDs = inputThreadIDs; if (!threadIDs) { return threadInfos; } return threadInfos.filter(threadInfo => threadIDs.has(threadInfo.id)); }, ); const onScreenEntryEditableThreadInfos: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( onScreenThreadInfos, (threadInfos: $ReadOnlyArray): $ReadOnlyArray => 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: $ReadOnlyArray, includeDeleted: boolean, ) => { const allDaysWithinRange: { [string]: string[] } = {}, 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: { [string]: ThreadInfo[], } = {}; for (const id in threadInfos) { const threadInfo = threadInfos[id]; const parentThreadID = threadInfo.parentThreadID; if (parentThreadID === null || parentThreadID === undefined) { continue; } if (result[parentThreadID] === undefined) { result[parentThreadID] = ([]: ThreadInfo[]); } result[parentThreadID].push(threadInfo); } return result; }, ); const containedThreadInfos: (state: BaseAppState<>) => { +[id: string]: $ReadOnlyArray, } = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result: { [string]: ThreadInfo[], } = {}; for (const id in threadInfos) { const threadInfo = threadInfos[id]; const { containingThreadID } = threadInfo; if (containingThreadID === null || containingThreadID === undefined) { continue; } if (result[containingThreadID] === undefined) { result[containingThreadID] = ([]: ThreadInfo[]); } result[containingThreadID].push(threadInfo); } return result; }, ); function getMostRecentRawMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, ): ?RawMessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (const 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.id, messageStore, ); sidebarInfos.push({ threadInfo: childThreadInfo, lastUpdatedTime, mostRecentNonLocalMessage, }); } return _orderBy('lastUpdatedTime')('desc')(sidebarInfos); }, ); const unreadCount: (state: BaseAppState<>) => number = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: RawThreadInfos): number => values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const unreadBackgroundCount: (state: BaseAppState<>) => number = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: RawThreadInfos): number => values(threadInfos).filter( threadInfo => threadInBackgroundChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const baseUnreadCountSelectorForCommunity: ( communityID: string, ) => (BaseAppState<>) => number = (communityID: string) => createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: RawThreadInfos): number => Object.values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread && (communityID === threadInfo.community || communityID === threadInfo.id), ).length, ); const unreadCountSelectorForCommunity: ( communityID: string, ) => (state: BaseAppState<>) => number = _memoize( baseUnreadCountSelectorForCommunity, ); const baseAncestorThreadInfos: ( threadID: string, ) => (BaseAppState<>) => $ReadOnlyArray = (threadID: string) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state), (threadInfos: { +[id: string]: ThreadInfo, }): $ReadOnlyArray => { const pathComponents: ThreadInfo[] = []; let node: ?ThreadInfo = threadInfos[threadID]; while (node) { pathComponents.push(node); node = node.parentThreadID ? threadInfos[node.parentThreadID] : null; } pathComponents.reverse(); return pathComponents; }, ); const ancestorThreadInfos: ( threadID: string, ) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseAncestorThreadInfos, ); const baseOtherUsersButNoOtherAdmins: ( threadID: string, ) => (BaseAppState<>) => boolean = (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 (const 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 mostRecentlyReadThread( messageStore: MessageStore, threadInfos: MixedRawThreadInfos, ): ?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 mostRecentlyReadThreadSelector: (state: BaseAppState<>) => ?string = createSelector( (state: BaseAppState<>) => state.messageStore, (state: BaseAppState<>) => state.threadStore.threadInfos, mostRecentlyReadThread, ); const threadInfoFromSourceMessageIDSelector: (state: BaseAppState<>) => { +[id: string]: ThreadInfo, } = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, threadInfoSelector, ( rawThreadInfos: RawThreadInfos, threadInfos: { +[id: string]: ThreadInfo, }, ) => { const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector(rawThreadInfos); const result: { [string]: ThreadInfo } = {}; for (const realizedID of pendingToRealizedThreadIDs.values()) { const threadInfo = threadInfos[realizedID]; if (threadInfo && threadInfo.sourceMessageID) { result[threadInfo.sourceMessageID] = threadInfo; } } return result; }, ); const pendingToRealizedThreadIDsSelector: ( rawThreadInfos: RawThreadInfos, ) => $ReadOnlyMap = createSelector( (rawThreadInfos: RawThreadInfos) => rawThreadInfos, (rawThreadInfos: RawThreadInfos) => { const result = new Map(); for (const threadID in rawThreadInfos) { const rawThreadInfo = rawThreadInfos[threadID]; if ( threadIsPending(threadID) || - (rawThreadInfo.parentThreadID !== genesis.id && + (rawThreadInfo.parentThreadID !== genesis().id && rawThreadInfo.type !== threadTypes.SIDEBAR) ) { continue; } const actualMemberIDs = rawThreadInfo.members .filter(member => member.role) .map(member => member.id); const pendingThreadID = getPendingThreadID( rawThreadInfo.type, actualMemberIDs, rawThreadInfo.sourceMessageID, ); const existingResult = result.get(pendingThreadID); if ( !existingResult || rawThreadInfos[existingResult].creationTime > rawThreadInfo.creationTime ) { result.set(pendingThreadID, threadID); } } return result; }, ); const baseSavedEmojiAvatarSelectorForThread: ( threadID: string, containingThreadID: ?string, ) => (BaseAppState<>) => () => ClientAvatar = ( threadID: string, containingThreadID: ?string, ) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state)[threadID], (state: BaseAppState<>) => containingThreadID ? threadInfoSelector(state)[containingThreadID] : null, (threadInfo: ThreadInfo, containingThreadInfo: ?ThreadInfo) => { return () => { let threadAvatar = getAvatarForThread(threadInfo, containingThreadInfo); if (threadAvatar.type !== 'emoji') { threadAvatar = getRandomDefaultEmojiAvatar(); } return threadAvatar; }; }, ); const savedEmojiAvatarSelectorForThread: ( threadID: string, containingThreadID: ?string, ) => (state: BaseAppState<>) => () => ClientEmojiAvatar = _memoize( baseSavedEmojiAvatarSelectorForThread, ); const baseThreadInfosSelectorForThreadType: ( threadType: ThreadType, ) => (BaseAppState<>) => $ReadOnlyArray = ( threadType: ThreadType, ) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state), (threadInfos: { +[id: string]: ThreadInfo, }): $ReadOnlyArray => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.type === threadType) { result.push(threadInfo); } } return result; }, ); const threadInfosSelectorForThreadType: ( threadType: ThreadType, ) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseThreadInfosSelectorForThreadType, ); export { ancestorThreadInfos, threadInfoSelector, communityThreadSelector, onScreenThreadInfos, onScreenEntryEditableThreadInfos, entryInfoSelector, currentDaysToEntries, childThreadInfos, containedThreadInfos, unreadCount, unreadBackgroundCount, unreadCountSelectorForCommunity, otherUsersButNoOtherAdmins, mostRecentlyReadThread, mostRecentlyReadThreadSelector, sidebarInfoSelector, threadInfoFromSourceMessageIDSelector, pendingToRealizedThreadIDsSelector, savedEmojiAvatarSelectorForThread, threadInfosSelectorForThreadType, }; diff --git a/lib/shared/ancestor-threads.js b/lib/shared/ancestor-threads.js index 7b2380bbb..15de581c3 100644 --- a/lib/shared/ancestor-threads.js +++ b/lib/shared/ancestor-threads.js @@ -1,33 +1,33 @@ // @flow import * as React from 'react'; import genesis from '../facts/genesis.js'; import { ancestorThreadInfos, threadInfoSelector, } from '../selectors/thread-selectors.js'; import { threadIsPending } from '../shared/thread-utils.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { useSelector } from '../utils/redux-utils.js'; function useAncestorThreads( threadInfo: ThreadInfo, ): $ReadOnlyArray { const ancestorThreads = useSelector(ancestorThreadInfos(threadInfo.id)); const genesisThreadInfo = useSelector( - state => threadInfoSelector(state)[genesis.id], + state => threadInfoSelector(state)[genesis().id], ); return React.useMemo(() => { if (!threadIsPending(threadInfo.id)) { return ancestorThreads.length > 1 ? ancestorThreads.slice(0, -1) : ancestorThreads; } return genesisThreadInfo ? [genesisThreadInfo] : []; }, [ancestorThreads, genesisThreadInfo, threadInfo.id]); } export { useAncestorThreads }; diff --git a/lib/shared/avatar-utils.js b/lib/shared/avatar-utils.js index 757a09f2b..4578328a3 100644 --- a/lib/shared/avatar-utils.js +++ b/lib/shared/avatar-utils.js @@ -1,366 +1,366 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import stringHash from 'string-hash'; import { selectedThreadColors } from './color-utils.js'; import { threadOtherMembers } from './thread-utils.js'; import genesis from '../facts/genesis.js'; import { useENSAvatar } from '../hooks/ens-cache.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import { getETHAddressForUserInfo } from '../shared/account-utils.js'; import type { ClientAvatar, ClientEmojiAvatar, GenericUserInfoWithAvatar, ResolvedClientAvatar, } from '../types/avatar-types.js'; import type { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { UserInfos } from '../types/user-types.js'; import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; import { useSelector } from '../utils/redux-utils.js'; const defaultAnonymousUserEmojiAvatar: ClientEmojiAvatar = { color: selectedThreadColors[4], emoji: '👤', type: 'emoji', }; const defaultEmojiAvatars: $ReadOnlyArray = [ { color: selectedThreadColors[0], emoji: '😀', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '😃', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😄', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😁', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '😆', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🙂', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '😉', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '😊', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '😇', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🥰', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '😍', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🤩', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🥳', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '😝', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😎', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🧐', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🥸', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🤗', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '😤', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🤯', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🤔', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🫡', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🤫', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😮', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '😲', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🤠', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🤑', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '👩‍🚀', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🥷', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '👻', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '👾', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🤖', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😺', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '😸', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '😹', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '😻', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🎩', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '👑', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🐶', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🐱', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐭', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🐹', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🐰', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🐻', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🐼', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐻‍❄️', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🐨', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🐯', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🦁', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐸', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐔', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🐧', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🐦', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🐤', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🦄', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐝', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🦋', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🐬', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🐳', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐋', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🦈', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🦭', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🐘', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🦛', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐐', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐓', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🦃', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🦩', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🦔', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐅', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🐆', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🦓', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🦒', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🦘', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐎', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐕', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🐩', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🦮', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🐈', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🦚', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🦜', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🦢', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🕊️', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🐇', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🦦', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐿️', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🐉', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🌴', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🌱', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '☘️', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍀', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🍄', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🌿', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🪴', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍁', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '💐', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🌷', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🌹', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🌸', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🌻', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '⭐', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🌟', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🍏', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🍎', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍐', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🍊', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🍋', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🍓', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🫐', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍈', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🍒', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🥭', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🍍', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🥝', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍅', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🥦', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🥕', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🥐', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🥯', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🍞', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🥖', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🥨', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🧀', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🥞', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🧇', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🥓', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🍔', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🍟', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🍕', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🥗', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍝', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🍜', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🍲', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🍛', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍣', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🍱', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🥟', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🍤', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🍙', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🍚', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍥', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🍦', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🧁', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🍭', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍩', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🍪', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '☕️', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🍵', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '⚽️', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🏀', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🏈', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '⚾️', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🥎', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🎾', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🏐', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🏉', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🎱', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🏆', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🎨', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🎤', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🎧', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🎼', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🎹', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🥁', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🎷', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🎺', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🎸', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🪕', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🎻', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🎲', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '♟️', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🎮', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🚗', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🚙', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🚌', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🏎️', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🛻', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🚚', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🚛', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🚘', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🚀', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🚁', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🛶', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '⛵️', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🚤', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '⚓', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🏰', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🎡', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '💎', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🔮', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '💈', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🧸', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🎊', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🎉', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🪩', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🚂', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🚆', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🚊', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🛰️', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🏠', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '⛰️', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🏔️', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🗻', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🏛️', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '⛩️', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🧲', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🎁', type: 'emoji' }, ]; function getRandomDefaultEmojiAvatar(): ClientEmojiAvatar { const randomIndex = Math.floor(Math.random() * defaultEmojiAvatars.length); return defaultEmojiAvatars[randomIndex]; } function getDefaultAvatar(hashKey: string, color?: string): ClientEmojiAvatar { let key = hashKey; if (key.startsWith(`${authoritativeKeyserverID()}|`)) { key = key.slice(`${authoritativeKeyserverID()}|`.length); } const avatarIndex = stringHash(key) % defaultEmojiAvatars.length; return { ...defaultEmojiAvatars[avatarIndex], color: color ? color : defaultEmojiAvatars[avatarIndex].color, }; } function getAvatarForUser(user: ?GenericUserInfoWithAvatar): ClientAvatar { if (user?.avatar) { return user.avatar; } if (!user?.username) { return defaultAnonymousUserEmojiAvatar; } return getDefaultAvatar(user.username); } function getUserAvatarForThread( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ClientAvatar { if (threadInfo.type === threadTypes.PRIVATE) { invariant(viewerID, 'viewerID should be set for PRIVATE threads'); return getAvatarForUser(userInfos[viewerID]); } invariant( threadInfo.type === threadTypes.PERSONAL, 'threadInfo should be a PERSONAL type', ); const memberInfos = threadOtherMembers(threadInfo.members, viewerID) .map(member => userInfos[member.id] && userInfos[member.id]) .filter(Boolean); if (memberInfos.length === 0) { return defaultAnonymousUserEmojiAvatar; } return getAvatarForUser(memberInfos[0]); } function getAvatarForThread( thread: RawThreadInfo | ThreadInfo, containingThreadInfo: ?ThreadInfo, ): ClientAvatar { if (thread.avatar) { return thread.avatar; } - if (containingThreadInfo && containingThreadInfo.id !== genesis.id) { + if (containingThreadInfo && containingThreadInfo.id !== genesis().id) { return containingThreadInfo.avatar ? containingThreadInfo.avatar : getDefaultAvatar(containingThreadInfo.id, containingThreadInfo.color); } return getDefaultAvatar(thread.id, thread.color); } function useAvatarForThread(thread: RawThreadInfo | ThreadInfo): ClientAvatar { const containingThreadID = thread.containingThreadID; const containingThreadInfo = useSelector(state => containingThreadID ? threadInfoSelector(state)[containingThreadID] : null, ); return getAvatarForThread(thread, containingThreadInfo); } function useENSResolvedAvatar( avatarInfo: ClientAvatar, userInfo: ?GenericUserInfoWithAvatar, ): ResolvedClientAvatar { const ethAddress = React.useMemo( () => getETHAddressForUserInfo(userInfo), [userInfo], ); const ensAvatarURI = useENSAvatar(ethAddress); const resolvedAvatar = React.useMemo(() => { if (avatarInfo.type !== 'ens') { return avatarInfo; } if (ensAvatarURI) { return { type: 'image', uri: ensAvatarURI, }; } return defaultAnonymousUserEmojiAvatar; }, [ensAvatarURI, avatarInfo]); return resolvedAvatar; } export { defaultAnonymousUserEmojiAvatar, defaultEmojiAvatars, getRandomDefaultEmojiAvatar, getDefaultAvatar, getAvatarForUser, getUserAvatarForThread, getAvatarForThread, useAvatarForThread, useENSResolvedAvatar, }; diff --git a/lib/shared/search-utils.js b/lib/shared/search-utils.js index c7af8d084..68f3f9872 100644 --- a/lib/shared/search-utils.js +++ b/lib/shared/search-utils.js @@ -1,451 +1,451 @@ // @flow import * as React from 'react'; import { messageID } from './message-utils.js'; import SearchIndex from './search-index.js'; import { getContainingThreadID, threadMemberHasPermission, userIsMember, } from './thread-utils.js'; import { searchMessagesActionTypes, useSearchMessages as useSearchMessagesAction, } from '../actions/message-actions.js'; import { searchUsers, searchUsersActionTypes, } from '../actions/user-actions.js'; import { ENSCacheContext } from '../components/ens-cache-provider.react.js'; import genesis from '../facts/genesis.js'; import type { ChatMessageInfoItem, MessageListData, } from '../selectors/chat-selectors.js'; import { useUserSearchIndex } from '../selectors/nav-selectors.js'; import { relationshipBlockedInEitherDirection } from '../shared/relationship-utils.js'; import type { MessageInfo, RawMessageInfo } from '../types/message-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes } from '../types/thread-types-enum.js'; import type { AccountUserInfo, GlobalAccountUserInfo, UserListItem, } from '../types/user-types.js'; import { useLegacyAshoatKeyserverCall } from '../utils/action-utils.js'; import { isValidENSName } from '../utils/ens-helpers.js'; import { values } from '../utils/objects.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; const notFriendNotice = 'not friend'; function appendUserInfo({ results, excludeUserIDs, userInfo, parentThreadInfo, communityThreadInfo, containingThreadInfo, }: { +results: { [id: string]: { ...AccountUserInfo | GlobalAccountUserInfo, isMemberOfParentThread: boolean, isMemberOfContainingThread: boolean, }, }, +excludeUserIDs: $ReadOnlyArray, +userInfo: AccountUserInfo | GlobalAccountUserInfo, +parentThreadInfo: ?ThreadInfo, +communityThreadInfo: ?ThreadInfo, +containingThreadInfo: ?ThreadInfo, }) { const { id } = userInfo; if (excludeUserIDs.includes(id) || id in results) { return; } if ( communityThreadInfo && !threadMemberHasPermission( communityThreadInfo, id, threadPermissions.KNOW_OF, ) ) { return; } results[id] = { ...userInfo, isMemberOfParentThread: userIsMember(parentThreadInfo, id), isMemberOfContainingThread: userIsMember(containingThreadInfo, id), }; } function usePotentialMemberItems({ text, userInfos, excludeUserIDs, includeServerSearchUsers, inputParentThreadInfo, inputCommunityThreadInfo, threadType, }: { +text: string, +userInfos: { +[id: string]: AccountUserInfo }, +excludeUserIDs: $ReadOnlyArray, +includeServerSearchUsers?: $ReadOnlyArray, +inputParentThreadInfo?: ?ThreadInfo, +inputCommunityThreadInfo?: ?ThreadInfo, +threadType?: ?ThreadType, }): UserListItem[] { const memoizedUserInfos = React.useMemo(() => values(userInfos), [userInfos]); const searchIndex: SearchIndex = useUserSearchIndex(memoizedUserInfos); const communityThreadInfo = React.useMemo( () => - inputCommunityThreadInfo && inputCommunityThreadInfo.id !== genesis.id + inputCommunityThreadInfo && inputCommunityThreadInfo.id !== genesis().id ? inputCommunityThreadInfo : null, [inputCommunityThreadInfo], ); const parentThreadInfo = React.useMemo( () => - inputParentThreadInfo && inputParentThreadInfo.id !== genesis.id + inputParentThreadInfo && inputParentThreadInfo.id !== genesis().id ? inputParentThreadInfo : null, [inputParentThreadInfo], ); const containgThreadID = threadType ? getContainingThreadID(parentThreadInfo, threadType) : null; const containingThreadInfo = React.useMemo(() => { if (containgThreadID === parentThreadInfo?.id) { return parentThreadInfo; } else if (containgThreadID === communityThreadInfo?.id) { return communityThreadInfo; } return null; }, [containgThreadID, communityThreadInfo, parentThreadInfo]); const filteredUserResults = React.useMemo(() => { const results: { [id: string]: { ...AccountUserInfo | GlobalAccountUserInfo, isMemberOfParentThread: boolean, isMemberOfContainingThread: boolean, }, } = {}; if (text === '') { for (const id in userInfos) { appendUserInfo({ results, excludeUserIDs, userInfo: userInfos[id], parentThreadInfo, communityThreadInfo, containingThreadInfo, }); } } else { const ids = searchIndex.getSearchResults(text); for (const id of ids) { appendUserInfo({ results, excludeUserIDs, userInfo: userInfos[id], parentThreadInfo, communityThreadInfo, containingThreadInfo, }); } } if (includeServerSearchUsers) { for (const userInfo of includeServerSearchUsers) { appendUserInfo({ results, excludeUserIDs, userInfo, parentThreadInfo, communityThreadInfo, containingThreadInfo, }); } } let userResults = values(results); if (text === '') { userResults = userResults.filter(userInfo => { if (!containingThreadInfo) { return userInfo.relationshipStatus === userRelationshipStatus.FRIEND; } if (!userInfo.isMemberOfContainingThread) { return false; } const { relationshipStatus } = userInfo; if (!relationshipStatus) { return true; } return !relationshipBlockedInEitherDirection(relationshipStatus); }); } return userResults; }, [ text, userInfos, searchIndex, excludeUserIDs, includeServerSearchUsers, parentThreadInfo, containingThreadInfo, communityThreadInfo, ]); const sortedMembers = React.useMemo(() => { const nonFriends = []; const blockedUsers = []; const friends = []; const containingThreadMembers = []; const parentThreadMembers = []; for (const userResult of filteredUserResults) { const { relationshipStatus } = userResult; if ( relationshipStatus && relationshipBlockedInEitherDirection(relationshipStatus) ) { blockedUsers.push(userResult); } else if (userResult.isMemberOfParentThread) { parentThreadMembers.push(userResult); } else if (userResult.isMemberOfContainingThread) { containingThreadMembers.push(userResult); } else if (relationshipStatus === userRelationshipStatus.FRIEND) { friends.push(userResult); } else { nonFriends.push(userResult); } } const sortedResults = parentThreadMembers .concat(containingThreadMembers) .concat(friends) .concat(nonFriends) .concat(blockedUsers); return sortedResults.map( ({ isMemberOfContainingThread, isMemberOfParentThread, relationshipStatus, ...result }) => { let notice, alert; const username = result.username; if ( relationshipStatus && relationshipBlockedInEitherDirection(relationshipStatus) ) { notice = 'user is blocked'; alert = { title: 'User is blocked', text: `Before you add ${username} to this chat, ` + 'you’ll need to unblock them. You can do this from the Block List ' + 'in the Profile tab.', }; } else if (!isMemberOfContainingThread && containingThreadInfo) { if (threadType !== threadTypes.SIDEBAR) { notice = 'not in community'; alert = { title: 'Not in community', text: 'You can only add members of the community to this chat', }; } else { notice = 'not in parent chat'; alert = { title: 'Not in parent chat', text: 'You can only add members of the parent chat to a thread', }; } } else if ( !containingThreadInfo && relationshipStatus !== userRelationshipStatus.FRIEND ) { notice = notFriendNotice; alert = { title: 'Not a friend', text: `Before you add ${username} to this chat, ` + 'you’ll need to send them a friend request. ' + 'You can do this from the Friend List in the Profile tab.', }; } else if (parentThreadInfo && !isMemberOfParentThread) { notice = 'not in parent chat'; } if (notice) { result = { ...result, notice }; } if (alert) { result = { ...result, alert }; } return result; }, ); }, [containingThreadInfo, filteredUserResults, parentThreadInfo, threadType]); return sortedMembers; } function useSearchMessages(): ( query: string, threadID: string, onResultsReceived: ( messages: $ReadOnlyArray, endReached: boolean, queryID: number, threadID: string, ) => mixed, queryID: number, cursor?: ?string, ) => void { const callSearchMessages = useSearchMessagesAction(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( (query, threadID, onResultsReceived, queryID, cursor) => { const searchMessagesPromise = (async () => { if (query === '') { onResultsReceived([], true, queryID, threadID); return; } const { messages, endReached } = await callSearchMessages({ query, threadID, cursor, }); onResultsReceived(messages, endReached, queryID, threadID); })(); void dispatchActionPromise( searchMessagesActionTypes, searchMessagesPromise, ); }, [callSearchMessages, dispatchActionPromise], ); } function useForwardLookupSearchText(originalText: string): string { const cacheContext = React.useContext(ENSCacheContext); const { ensCache } = cacheContext; const lowercaseText = originalText.toLowerCase(); const [usernameToSearch, setUsernameToSearch] = React.useState(lowercaseText); React.useEffect(() => { void (async () => { if (!ensCache || !isValidENSName(lowercaseText)) { setUsernameToSearch(lowercaseText); return; } const address = await ensCache.getAddressForName(lowercaseText); if (address) { setUsernameToSearch(address); } else { setUsernameToSearch(lowercaseText); } })(); }, [ensCache, lowercaseText]); return usernameToSearch; } function useSearchUsers( usernameInputText: string, ): $ReadOnlyArray { const currentUserID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const forwardLookupSearchText = useForwardLookupSearchText(usernameInputText); const [serverSearchResults, setServerSearchResults] = React.useState< $ReadOnlyArray, >([]); const callSearchUsers = useLegacyAshoatKeyserverCall(searchUsers); const dispatchActionPromise = useDispatchActionPromise(); React.useEffect(() => { if (forwardLookupSearchText.length === 0) { setServerSearchResults([]); return; } const searchUsersPromise = (async () => { try { const { userInfos } = await callSearchUsers(forwardLookupSearchText); setServerSearchResults( userInfos.filter(({ id }) => id !== currentUserID), ); } catch (err) { setServerSearchResults([]); } })(); void dispatchActionPromise(searchUsersActionTypes, searchUsersPromise); }, [ callSearchUsers, currentUserID, dispatchActionPromise, forwardLookupSearchText, ]); return serverSearchResults; } function filterChatMessageInfosForSearch( chatMessageInfos: MessageListData, translatedSearchResults: $ReadOnlyArray, ): ?(ChatMessageInfoItem[]) { if (!chatMessageInfos) { return null; } const idSet = new Set(translatedSearchResults.map(messageID)); const uniqueChatMessageInfoItemsMap = new Map(); for (const item of chatMessageInfos) { if (item.itemType !== 'message' || item.messageInfoType !== 'composable') { continue; } const id = messageID(item.messageInfo); if (idSet.has(id)) { uniqueChatMessageInfoItemsMap.set(id, item); } } const sortedChatMessageInfoItems: ChatMessageInfoItem[] = []; for (let i = 0; i < translatedSearchResults.length; i++) { const id = messageID(translatedSearchResults[i]); const match = uniqueChatMessageInfoItemsMap.get(id); if (match) { sortedChatMessageInfoItems.push(match); } } return sortedChatMessageInfoItems; } export { usePotentialMemberItems, notFriendNotice, useSearchMessages, useSearchUsers, filterChatMessageInfosForSearch, useForwardLookupSearchText, }; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index c19a918d2..29069c7ee 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1886 +1,1886 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omitBy from 'lodash/fp/omitBy.js'; import * as React from 'react'; import { getUserAvatarForThread } from './avatar-utils.js'; import { generatePendingThreadColor } from './color-utils.js'; import { type ParserRules } from './markdown.js'; import { extractUserMentionsFromText } from './mention-utils.js'; import { getMessageTitle, isInvalidSidebarSource } from './message-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import { useForwardLookupSearchText } from './search-utils.js'; import threadWatcher from './thread-watcher.js'; import { fetchMostRecentMessagesActionTypes, useFetchMostRecentMessages, } from '../actions/message-actions.js'; import type { RemoveUsersFromThreadInput } from '../actions/thread-actions'; import { newThreadActionTypes, removeUsersFromThreadActionTypes, } from '../actions/thread-actions.js'; import { searchUsers as searchUserCall } from '../actions/user-actions.js'; import ashoat from '../facts/ashoat.js'; import genesis from '../facts/genesis.js'; import { useLoggedInUserInfo } from '../hooks/account-hooks.js'; import { hasPermission, permissionsToBitmaskHex, threadPermissionsFromBitmaskHex, } from '../permissions/minimally-encoded-thread-permissions.js'; import { specialRoles } from '../permissions/special-roles.js'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions.js'; import type { ChatThreadItem, ChatMessageInfoItem, } from '../selectors/chat-selectors.js'; import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { threadInfoSelector, pendingToRealizedThreadIDsSelector, threadInfosSelectorForThreadType, } from '../selectors/thread-selectors.js'; import { getRelativeMemberInfos, usersWithPersonalThreadSelector, } from '../selectors/user-selectors.js'; import type { CalendarQuery } from '../types/entry-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type RobotextMessageInfo, type ComposableMessageInfo, } from '../types/message-types.js'; import type { RelativeMemberInfo, RawThreadInfo, MemberInfo, ThreadCurrentUserInfo, RoleInfo, ThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { decodeMinimallyEncodedRoleInfo, minimallyEncodeMemberInfo, minimallyEncodeRawThreadInfo, minimallyEncodeRoleInfo, minimallyEncodeThreadCurrentUserInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissionPropagationPrefixes, threadPermissions, configurableCommunityPermissions, type ThreadPermission, type ThreadPermissionsInfo, type ThreadRolePermissionsBlob, type UserSurfacedPermission, threadPermissionFilterPrefixes, } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes, threadTypeIsCommunityRoot, assertThreadType, } from '../types/thread-types-enum.js'; import type { LegacyRawThreadInfo, ClientLegacyRoleInfo, ServerThreadInfo, ServerMemberInfo, ClientNewThreadRequest, NewThreadResult, ChangeThreadSettingsPayload, UserProfileThreadInfo, MixedRawThreadInfos, LegacyMemberInfo, } from '../types/thread-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { type ClientUpdateInfo } from '../types/update-types.js'; import type { GlobalAccountUserInfo, UserInfos, AccountUserInfo, LoggedInUserInfo, UserInfo, } from '../types/user-types.js'; import { useLegacyAshoatKeyserverCall } from '../utils/action-utils.js'; import type { GetENSNames } from '../utils/ens-helpers.js'; import { ET, entityTextToRawString, getEntityTextAsString, type ThreadEntity, type UserEntity, } from '../utils/entity-text.js'; import { entries } from '../utils/objects.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; import { firstLine } from '../utils/string-utils.js'; import { trimText } from '../utils/text-utils.js'; import { pendingThreadIDRegex } from '../utils/validation-utils.js'; function threadHasPermission( threadInfo: ?(ThreadInfo | LegacyRawThreadInfo | RawThreadInfo), permission: ThreadPermission, ): boolean { if (!threadInfo) { return false; } 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.minimallyEncoded) { return hasPermission(threadInfo.currentUser.permissions, permission); } return permissionLookup(threadInfo.currentUser.permissions, permission); } function viewerIsMember( threadInfo: ?(ThreadInfo | LegacyRawThreadInfo | RawThreadInfo), ): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList( threadInfo: ?(LegacyRawThreadInfo | RawThreadInfo | ThreadInfo), ): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function threadIsTopLevel(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return threadInChatList(threadInfo) && threadIsChannel(threadInfo); } function threadIsChannel(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return !!(threadInfo && threadInfo.type !== threadTypes.SIDEBAR); } function threadIsSidebar(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return threadInfo?.type === threadTypes.SIDEBAR; } function threadInBackgroundChatList( threadInfo: ?(RawThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?(RawThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?(LegacyRawThreadInfo | RawThreadInfo | ThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?(RawThreadInfo | ThreadInfo), userID: string, ): boolean { if (!threadInfo) { return false; } - if (threadInfo.id === genesis.id) { + if (threadInfo.id === genesis().id) { return true; } return threadInfo.members.some(member => member.id === userID && member.role); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter(memberInfo => memberInfo.role) .map(memberInfo => memberInfo.id); } function threadOtherMembers< T: LegacyMemberInfo | MemberInfo | RelativeMemberInfo, >(memberInfos: $ReadOnlyArray, viewerID: ?string): $ReadOnlyArray { return memberInfos.filter( memberInfo => memberInfo.role && memberInfo.id !== viewerID, ); } function threadMembersWithoutAddedAshoat< T: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, >(threadInfo: T): $PropertyType { - if (threadInfo.community !== genesis.id) { + if (threadInfo.community !== genesis().id) { return threadInfo.members; } return threadInfo.members.filter( member => member.id !== ashoat.id || member.role, ); } function threadIsGroupChat(threadInfo: ThreadInfo): boolean { return threadInfo.members.length > 2; } function threadOrParentThreadIsGroupChat( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ) { return threadMembersWithoutAddedAshoat(threadInfo).length > 2; } function threadIsPending(threadID: ?string): boolean { return !!threadID?.startsWith('pending'); } function threadIsPendingSidebar(threadID: ?string): boolean { return !!threadID?.startsWith('pending/sidebar/'); } function getSingleOtherUser( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, viewerID: ?string, ): ?string { if (!viewerID) { return undefined; } const otherMembers = threadOtherMembers(threadInfo.members, viewerID); if (otherMembers.length !== 1) { return undefined; } return otherMembers[0].id; } function getPendingThreadID( threadType: ThreadType, memberIDs: $ReadOnlyArray, sourceMessageID: ?string, ): string { const pendingThreadKey = sourceMessageID ? `sidebar/${sourceMessageID}` : [...memberIDs].sort().join('+'); const pendingThreadTypeString = sourceMessageID ? '' : `type${threadType}/`; return `pending/${pendingThreadTypeString}${pendingThreadKey}`; } type PendingThreadIDContents = { +threadType: ThreadType, +memberIDs: $ReadOnlyArray, +sourceMessageID: ?string, }; function parsePendingThreadID( pendingThreadID: string, ): ?PendingThreadIDContents { const pendingRegex = new RegExp(`^${pendingThreadIDRegex}$`); const pendingThreadIDMatches = pendingRegex.exec(pendingThreadID); if (!pendingThreadIDMatches) { return null; } const [threadTypeString, threadKey] = pendingThreadIDMatches[1].split('/'); const threadType = threadTypeString === 'sidebar' ? threadTypes.SIDEBAR : assertThreadType(Number(threadTypeString.replace('type', ''))); const memberIDs = threadTypeString === 'sidebar' ? [] : threadKey.split('+'); const sourceMessageID = threadTypeString === 'sidebar' ? threadKey : null; return { threadType, memberIDs, sourceMessageID, }; } type UserIDAndUsername = { +id: string, +username: string, ... }; type CreatePendingThreadArgs = { +viewerID: string, +threadType: ThreadType, +members: $ReadOnlyArray, +parentThreadInfo?: ?ThreadInfo, +threadColor?: ?string, +name?: ?string, +sourceMessageID?: string, }; function createPendingThread({ viewerID, threadType, members, parentThreadInfo, threadColor, name, sourceMessageID, }: CreatePendingThreadArgs): ThreadInfo { const now = Date.now(); if (!members.some(member => member.id === viewerID)) { throw new Error( 'createPendingThread should be called with the viewer as a member', ); } const memberIDs = members.map(member => member.id); const threadID = getPendingThreadID(threadType, memberIDs, sourceMessageID); const permissions: ThreadRolePermissionsBlob = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role: RoleInfo = { ...minimallyEncodeRoleInfo({ id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }), specialRole: specialRoles.DEFAULT_ROLE, }; const rawThreadInfo: RawThreadInfo = { minimallyEncoded: true, id: threadID, type: threadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID(parentThreadInfo, threadType), community: getCommunity(parentThreadInfo), members: members.map(member => minimallyEncodeMemberInfo({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, }), ), roles: { [role.id]: role, }, currentUser: minimallyEncodeThreadCurrentUserInfo({ role: role.id, permissions: membershipPermissions, subscription: { pushNotifs: false, home: false, }, unread: false, }), repliesCount: 0, sourceMessageID, pinnedCount: 0, }; const userInfos: { [string]: UserInfo } = {}; for (const member of members) { const { id, username } = member; userInfos[id] = { id, username }; } return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } type PendingPersonalThread = { +threadInfo: ThreadInfo, +pendingPersonalThreadUserInfo: UserInfo, }; function createPendingPersonalThread( loggedInUserInfo: LoggedInUserInfo, userID: string, username: string, ): PendingPersonalThread { const pendingPersonalThreadUserInfo = { id: userID, username: username, }; const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PERSONAL, members: [loggedInUserInfo, pendingPersonalThreadUserInfo], }); return { threadInfo, pendingPersonalThreadUserInfo }; } function createPendingThreadItem( loggedInUserInfo: LoggedInUserInfo, user: UserIDAndUsername, ): ChatThreadItem { const { threadInfo, pendingPersonalThreadUserInfo } = createPendingPersonalThread(loggedInUserInfo, user.id, user.username); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo, }; } // Returns map from lowercase username to AccountUserInfo function memberLowercaseUsernameMap( members: $ReadOnlyArray, ): Map { const memberMap = new Map(); for (const member of members) { const { id, role, username } = member; if (!role || !username) { continue; } memberMap.set(username.toLowerCase(), { id, username }); } return memberMap; } // Returns map from user ID to AccountUserInfo function extractMentionedMembers( text: string, threadInfo: ThreadInfo, ): Map { const memberMap = memberLowercaseUsernameMap(threadInfo.members); const mentions = extractUserMentionsFromText(text); const mentionedMembers = new Map(); for (const mention of mentions) { const userInfo = memberMap.get(mention.toLowerCase()); if (userInfo) { mentionedMembers.set(userInfo.id, userInfo); } } return mentionedMembers; } // When a member of the parent is mentioned in a sidebar, // they will be automatically added to that sidebar function extractNewMentionedParentMembers( messageText: string, threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, ): AccountUserInfo[] { const mentionedMembersOfParent = extractMentionedMembers( messageText, parentThreadInfo, ); for (const member of threadInfo.members) { if (member.role) { mentionedMembersOfParent.delete(member.id); } } return [...mentionedMembersOfParent.values()]; } type SharedCreatePendingSidebarInput = { +sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, +parentThreadInfo: ThreadInfo, +loggedInUserInfo: LoggedInUserInfo, }; type BaseCreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +messageTitle: string, }; function baseCreatePendingSidebar( input: BaseCreatePendingSidebarInput, ): ThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, messageTitle, } = input; const { color, type: parentThreadType } = parentThreadInfo; const threadName = trimText(messageTitle, 30); const initialMembers = new Map(); const { id: viewerID, username: viewerUsername } = loggedInUserInfo; initialMembers.set(viewerID, { id: viewerID, username: viewerUsername }); if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) { const { id: sourceAuthorID, username: sourceAuthorUsername } = sourceMessageInfo.creator; invariant( sourceAuthorUsername, 'sourceAuthorUsername should be set in createPendingSidebar', ); const initialMemberUserInfo = { id: sourceAuthorID, username: sourceAuthorUsername, }; initialMembers.set(sourceAuthorID, initialMemberUserInfo); } const singleOtherUser = getSingleOtherUser(parentThreadInfo, viewerID); if (parentThreadType === threadTypes.PERSONAL && singleOtherUser) { const singleOtherUsername = parentThreadInfo.members.find( member => member.id === singleOtherUser, )?.username; invariant( singleOtherUsername, 'singleOtherUsername should be set in createPendingSidebar', ); const singleOtherUserInfo = { id: singleOtherUser, username: singleOtherUsername, }; initialMembers.set(singleOtherUser, singleOtherUserInfo); } if (sourceMessageInfo.type === messageTypes.TEXT) { const mentionedMembersOfParent = extractMentionedMembers( sourceMessageInfo.text, parentThreadInfo, ); for (const [memberID, member] of mentionedMembersOfParent) { initialMembers.set(memberID, member); } } return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members: [...initialMembers.values()], parentThreadInfo, threadColor: color, name: threadName, sourceMessageID: sourceMessageInfo.id, }); } // The message title here may have ETH addresses that aren't resolved to ENS // names. This function should only be used in cases where we're sure that we // don't care about the thread title. We should prefer createPendingSidebar // wherever possible type CreateUnresolvedPendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, }; function createUnresolvedPendingSidebar( input: CreateUnresolvedPendingSidebarInput, ): ThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = entityTextToRawString(messageTitleEntityText, { ignoreViewer: true, }); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } type CreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, +getENSNames: ?GetENSNames, }; async function createPendingSidebar( input: CreatePendingSidebarInput, ): Promise { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, getENSNames, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = await getEntityTextAsString( messageTitleEntityText, getENSNames, { ignoreViewer: true }, ); invariant( messageTitle !== null && messageTitle !== undefined, 'getEntityTextAsString only returns falsey when passed falsey', ); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } function pendingThreadType(numberOfOtherMembers: number): 4 | 6 | 7 { if (numberOfOtherMembers === 0) { return threadTypes.PRIVATE; } else if (numberOfOtherMembers === 1) { return threadTypes.PERSONAL; } else { return threadTypes.LOCAL; } } function threadTypeCanBePending(threadType: ThreadType): boolean { return ( threadType === threadTypes.PERSONAL || threadType === threadTypes.LOCAL || threadType === threadTypes.SIDEBAR || threadType === threadTypes.PRIVATE ); } type CreateRealThreadParameters = { +threadInfo: ThreadInfo, +dispatchActionPromise: DispatchActionPromise, +createNewThread: ClientNewThreadRequest => Promise, +sourceMessageID: ?string, +viewerID: ?string, +handleError?: () => mixed, +calendarQuery: CalendarQuery, }; async function createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise, createNewThread, sourceMessageID, viewerID, calendarQuery, }: CreateRealThreadParameters): Promise { if (!threadIsPending(threadInfo.id)) { return threadInfo.id; } const otherMemberIDs = threadOtherMembers(threadInfo.members, viewerID).map( member => member.id, ); let resultPromise; if (threadInfo.type !== threadTypes.SIDEBAR) { invariant( otherMemberIDs.length > 0, 'otherMemberIDs should not be empty for threads', ); resultPromise = createNewThread({ type: pendingThreadType(otherMemberIDs.length), initialMemberIDs: otherMemberIDs, color: threadInfo.color, calendarQuery, }); } else { invariant( sourceMessageID, 'sourceMessageID should be set when creating a sidebar', ); resultPromise = createNewThread({ type: threadTypes.SIDEBAR, initialMemberIDs: otherMemberIDs, color: threadInfo.color, sourceMessageID, parentThreadID: threadInfo.parentThreadID, name: threadInfo.name, calendarQuery, }); } void dispatchActionPromise(newThreadActionTypes, resultPromise); const { newThreadID } = await resultPromise; return newThreadID; } type RawThreadInfoOptions = { +filterThreadEditAvatarPermission?: boolean, +excludePinInfo?: boolean, +filterManageInviteLinksPermission?: boolean, +filterVoicedInAnnouncementChannelsPermission?: boolean, +minimallyEncodePermissions?: boolean, +includeSpecialRoleFieldInRoles?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?LegacyRawThreadInfo | ?RawThreadInfo { const filterThreadEditAvatarPermission = options?.filterThreadEditAvatarPermission; const excludePinInfo = options?.excludePinInfo; const filterManageInviteLinksPermission = options?.filterManageInviteLinksPermission; const filterVoicedInAnnouncementChannelsPermission = options?.filterVoicedInAnnouncementChannelsPermission; const shouldMinimallyEncodePermissions = options?.minimallyEncodePermissions; const shouldIncludeSpecialRoleFieldInRoles = options?.includeSpecialRoleFieldInRoles; const filterThreadPermissions = _omitBy( (v, k) => (filterThreadEditAvatarPermission && [ threadPermissions.EDIT_THREAD_AVATAR, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR, ].includes(k)) || (excludePinInfo && [ threadPermissions.MANAGE_PINS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.MANAGE_PINS, ].includes(k)) || (filterManageInviteLinksPermission && [threadPermissions.MANAGE_INVITE_LINKS].includes(k)) || (filterVoicedInAnnouncementChannelsPermission && [ threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.TOP_LEVEL + threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, ].includes(k)), ); const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( - serverThreadInfo.id === genesis.id && + serverThreadInfo.id === genesis().id && serverMember.id !== viewerID && serverMember.id !== ashoat.id ) { continue; } const memberPermissions = filterThreadPermissions(serverMember.permissions); members.push({ id: serverMember.id, role: serverMember.role, permissions: memberPermissions, isSender: serverMember.isSender, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: memberPermissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = filterThreadPermissions( 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 rolesWithFilteredThreadPermissions = _mapValues(role => ({ ...role, permissions: filterThreadPermissions(role.permissions), }))(serverThreadInfo.roles); const rolesWithoutSpecialRoleField = _mapValues(role => { const { specialRole, ...roleSansSpecialRole } = role; return roleSansSpecialRole; })(rolesWithFilteredThreadPermissions); let rawThreadInfo: any = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: rolesWithoutSpecialRoleField, currentUser, repliesCount: serverThreadInfo.repliesCount, containingThreadID: serverThreadInfo.containingThreadID, community: serverThreadInfo.community, }; const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } if (serverThreadInfo.avatar) { rawThreadInfo = { ...rawThreadInfo, avatar: serverThreadInfo.avatar }; } if (!excludePinInfo) { rawThreadInfo = { ...rawThreadInfo, pinnedCount: serverThreadInfo.pinnedCount, }; } if (!shouldMinimallyEncodePermissions) { return rawThreadInfo; } else if (!shouldIncludeSpecialRoleFieldInRoles) { return minimallyEncodeRawThreadInfo(rawThreadInfo); } const rawThreadInfoWithoutSpecialRoles = minimallyEncodeRawThreadInfo(rawThreadInfo); const rolesWithSpecialRoleField = Object.fromEntries( entries(rawThreadInfoWithoutSpecialRoles.roles).map(([key, role]) => [ key, { ...role, specialRole: rolesWithFilteredThreadPermissions[key]?.specialRole, }, ]), ); const rawThreadInfoWithSpecialRolesPatchedIn = { ...rawThreadInfoWithoutSpecialRoles, roles: rolesWithSpecialRoleField, }; return rawThreadInfoWithSpecialRolesPatchedIn; } function threadUIName(threadInfo: ThreadInfo): string | ThreadEntity { if (threadInfo.name) { return firstLine(threadInfo.name); } const threadMembers: $ReadOnlyArray = threadInfo.members.filter(memberInfo => memberInfo.role); const memberEntities: $ReadOnlyArray = threadMembers.map(member => ET.user({ userInfo: member }), ); return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: memberEntities, ifJustViewer: threadInfo.type === threadTypes.PRIVATE ? 'viewer_username' : 'just_you_string', }; } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { let threadInfo: ThreadInfo = { minimallyEncoded: true, id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: '', description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, containingThreadID: rawThreadInfo.containingThreadID, community: rawThreadInfo.community, members: getRelativeMemberInfos(rawThreadInfo, viewerID, userInfos), roles: rawThreadInfo.roles, currentUser: getMinimallyEncodedCurrentUser( rawThreadInfo, viewerID, userInfos, ), repliesCount: rawThreadInfo.repliesCount, }; threadInfo = { ...threadInfo, uiName: threadUIName(threadInfo), }; const { sourceMessageID, avatar, pinnedCount } = rawThreadInfo; if (sourceMessageID) { threadInfo = { ...threadInfo, sourceMessageID }; } if (avatar) { threadInfo = { ...threadInfo, avatar }; } else if ( rawThreadInfo.type === threadTypes.PERSONAL || rawThreadInfo.type === threadTypes.PRIVATE ) { threadInfo = { ...threadInfo, avatar: getUserAvatarForThread(rawThreadInfo, viewerID, userInfos), }; } if (pinnedCount) { threadInfo = { ...threadInfo, pinnedCount }; } return threadInfo; } function getMinimallyEncodedCurrentUser( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } const decodedPermissions = threadPermissionsFromBitmaskHex( threadInfo.currentUser.permissions, ); const updatedPermissions = { ...decodedPermissions, ...disabledPermissions, }; const encodedUpdatedPermissions = permissionsToBitmaskHex(updatedPermissions); return { ...threadInfo.currentUser, permissions: encodedUpdatedPermissions, }; } function threadIsWithBlockedUserOnly( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo) ) { return false; } const otherUserID = getSingleOtherUser(threadInfo, viewerID); if (!otherUserID) { return false; } const otherUserRelationshipStatus = userInfos[otherUserID]?.relationshipStatus; if (checkOnlyViewerBlock) { return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ); } return ( !!otherUserRelationshipStatus && relationshipBlockedInEitherDirection(otherUserRelationshipStatus) ); } function threadFrozenDueToBlock( threadInfo: LegacyRawThreadInfo | 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); } const threadTypeDescriptions: { [ThreadType]: string } = { [threadTypes.COMMUNITY_OPEN_SUBTHREAD]: 'Anybody in the parent channel can see an open subchannel.', [threadTypes.COMMUNITY_SECRET_SUBTHREAD]: 'Only visible to its members and admins of ancestor channels.', }; // 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 | LegacyMemberInfo | MemberInfo | ServerMemberInfo, ): boolean { if (memberInfo.minimallyEncoded) { return hasPermission(memberInfo.permissions, threadPermissions.CHANGE_ROLE); } return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsDefaultRole( roleInfo: ?ClientLegacyRoleInfo | ?RoleInfo, ): boolean { return !!(roleInfo && roleInfo.isDefault); } function roleIsAdminRole(roleInfo: ?ClientLegacyRoleInfo | ?RoleInfo): boolean { return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } function threadHasAdminRole( threadInfo: ?( | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo | ServerThreadInfo ), ): boolean { if (!threadInfo) { return false; } return !!_find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ) { return ( threadMembersWithoutAddedAshoat(threadInfo).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_NAME, threadPermissions.EDIT_THREAD_COLOR, threadPermissions.EDIT_THREAD_DESCRIPTION, threadPermissions.CREATE_SUBCHANNELS, 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: string = `Background chats are just like normal chats, except they don't ` + `contribute to your unread count.\n\n` + `To move a chat over here, switch the “Background” option in its settings.`; function threadNoun(threadType: ThreadType, parentThreadID: ?string): string { if (threadType === threadTypes.SIDEBAR) { return 'thread'; } else if ( threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD && - parentThreadID === genesis.id + parentThreadID === genesis().id ) { return 'chat'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.GENESIS ) { return 'channel'; } else { return 'chat'; } } function threadLabel(threadType: ThreadType): string { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ) { return 'Open'; } else if (threadType === threadTypes.PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { return 'Thread'; } else if (threadType === threadTypes.PRIVATE) { return 'Private'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS ) { return 'Community'; } else { return 'Secret'; } } function useWatchThread(threadInfo: ?ThreadInfo) { const dispatchActionPromise = useDispatchActionPromise(); const callFetchMostRecentMessages = useFetchMostRecentMessages(); const threadID = threadInfo?.id; const threadNotInChatList = !threadInChatList(threadInfo); React.useEffect(() => { if (threadID && threadNotInChatList) { threadWatcher.watchID(threadID); void dispatchActionPromise( fetchMostRecentMessagesActionTypes, callFetchMostRecentMessages({ threadID }), ); } return () => { if (threadID && threadNotInChatList) { threadWatcher.removeID(threadID); } }; }, [ callFetchMostRecentMessages, dispatchActionPromise, threadNotInChatList, threadID, ]); } type ExistingThreadInfoFinderParams = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, }; type ExistingThreadInfoFinder = ( params: ExistingThreadInfoFinderParams, ) => ?ThreadInfo; function useExistingThreadInfoFinder( baseThreadInfo: ?ThreadInfo, ): ExistingThreadInfoFinder { const threadInfos = useSelector(threadInfoSelector); const loggedInUserInfo = useLoggedInUserInfo(); const userInfos = useSelector(state => state.userStore.userInfos); const pendingToRealizedThreadIDs = useSelector(state => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); return React.useCallback( (params: ExistingThreadInfoFinderParams): ?ThreadInfo => { if (!baseThreadInfo) { return null; } const realizedThreadInfo = threadInfos[baseThreadInfo.id]; if (realizedThreadInfo) { return realizedThreadInfo; } if (!loggedInUserInfo || !threadIsPending(baseThreadInfo.id)) { return baseThreadInfo; } const viewerID = loggedInUserInfo?.id; invariant( threadTypeCanBePending(baseThreadInfo.type), `ThreadInfo has pending ID ${baseThreadInfo.id}, but type that ` + `should not be pending ${baseThreadInfo.type}`, ); const { searching, userInfoInputArray } = params; const { sourceMessageID } = baseThreadInfo; const pendingThreadID = searching ? getPendingThreadID( pendingThreadType(userInfoInputArray.length), [...userInfoInputArray.map(user => user.id), viewerID], sourceMessageID, ) : getPendingThreadID( baseThreadInfo.type, baseThreadInfo.members.map(member => member.id), sourceMessageID, ); const realizedThreadID = pendingToRealizedThreadIDs.get(pendingThreadID); if (realizedThreadID && threadInfos[realizedThreadID]) { return threadInfos[realizedThreadID]; } const updatedThread = searching ? createPendingThread({ viewerID, threadType: pendingThreadType(userInfoInputArray.length), members: [loggedInUserInfo, ...userInfoInputArray], }) : baseThreadInfo; return { ...updatedThread, currentUser: getMinimallyEncodedCurrentUser( updatedThread, viewerID, userInfos, ), }; }, [ baseThreadInfo, threadInfos, loggedInUserInfo, pendingToRealizedThreadIDs, userInfos, ], ); } type ThreadTypeParentRequirement = 'optional' | 'required' | 'disabled'; function getThreadTypeParentRequirement( threadType: ThreadType, ): ThreadTypeParentRequirement { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || //threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.SIDEBAR ) { return 'required'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS || threadType === threadTypes.PERSONAL || threadType === threadTypes.PRIVATE ) { return 'disabled'; } else { return 'optional'; } } function threadMemberHasPermission( threadInfo: | ServerThreadInfo | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, memberID: string, permission: ThreadPermission, ): boolean { for (const member of threadInfo.members) { if (member.id !== memberID) { continue; } if (member.minimallyEncoded) { return hasPermission(member.permissions, permission); } return permissionLookup(member.permissions, permission); } return false; } function useCanCreateSidebarFromMessage( threadInfo: ThreadInfo, messageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const messageCreatorUserInfo = useSelector( state => state.userStore.userInfos[messageInfo.creator.id], ); if ( !messageInfo.id || threadInfo.sourceMessageID === messageInfo.id || isInvalidSidebarSource(messageInfo) ) { return false; } const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; const creatorRelationshipHasBlock = messageCreatorRelationship && relationshipBlockedInEitherDirection(messageCreatorRelationship); const hasCreateSidebarsPermission = threadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); return hasCreateSidebarsPermission && !creatorRelationshipHasBlock; } function useSidebarExistsOrCanBeCreated( threadInfo: ThreadInfo, messageItem: ChatMessageInfoItem, ): boolean { const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( threadInfo, messageItem.messageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } function checkIfDefaultMembersAreVoiced(threadInfo: ThreadInfo): boolean { const defaultRoleID = Object.keys(threadInfo.roles).find(roleID => roleIsDefaultRole(threadInfo.roles[roleID]), ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = threadInfo.roles[defaultRoleID]; const defaultRolePermissions = decodeMinimallyEncodedRoleInfo(defaultRole).permissions; return !!defaultRolePermissions[threadPermissions.VOICED]; } const draftKeySuffix = '/message_composer'; function draftKeyFromThreadID(threadID: string): string { return `${threadID}${draftKeySuffix}`; } function getContainingThreadID( parentThreadInfo: | ?ServerThreadInfo | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, threadType: ThreadType, ): ?string { if (!parentThreadInfo) { return null; } if (threadType === threadTypes.SIDEBAR) { return parentThreadInfo.id; } if (!parentThreadInfo.containingThreadID) { return parentThreadInfo.id; } return parentThreadInfo.containingThreadID; } function getCommunity( parentThreadInfo: | ?ServerThreadInfo | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ): ?string { if (!parentThreadInfo) { return null; } const { id, community, type } = parentThreadInfo; if (community !== null && community !== undefined) { return community; } if (threadTypeIsCommunityRoot(type)) { return id; } return null; } function getThreadListSearchResults( chatListData: $ReadOnlyArray, searchText: string, threadFilter: ThreadInfo => boolean, threadSearchResults: $ReadOnlySet, usersSearchResults: $ReadOnlyArray, loggedInUserInfo: ?LoggedInUserInfo, ): $ReadOnlyArray { if (!searchText) { return chatListData.filter( item => threadIsTopLevel(item.threadInfo) && threadFilter(item.threadInfo), ); } const privateThreads = []; const personalThreads = []; const otherThreads = []; for (const item of chatListData) { if (!threadSearchResults.has(item.threadInfo.id)) { continue; } if (item.threadInfo.type === threadTypes.PRIVATE) { privateThreads.push({ ...item, sidebars: [] }); } else if (item.threadInfo.type === threadTypes.PERSONAL) { personalThreads.push({ ...item, sidebars: [] }); } else { otherThreads.push({ ...item, sidebars: [] }); } } const chatItems: ChatThreadItem[] = [ ...privateThreads, ...personalThreads, ...otherThreads, ]; if (loggedInUserInfo) { chatItems.push( ...usersSearchResults.map(user => createPendingThreadItem(loggedInUserInfo, user), ), ); } return chatItems; } type ThreadListSearchResult = { +threadSearchResults: $ReadOnlySet, +usersSearchResults: $ReadOnlyArray, }; function useThreadListSearch( searchText: string, viewerID: ?string, ): ThreadListSearchResult { const callSearchUsers = useLegacyAshoatKeyserverCall(searchUserCall); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); const forwardLookupSearchText = useForwardLookupSearchText(searchText); const searchUsers = React.useCallback( async (usernamePrefix: string) => { if (usernamePrefix.length === 0) { return ([]: GlobalAccountUserInfo[]); } const { userInfos } = await callSearchUsers(usernamePrefix); return userInfos.filter( info => !usersWithPersonalThread.has(info.id) && info.id !== viewerID, ); }, [callSearchUsers, usersWithPersonalThread, viewerID], ); const [threadSearchResults, setThreadSearchResults] = React.useState( new Set(), ); const [usersSearchResults, setUsersSearchResults] = React.useState< $ReadOnlyArray, >([]); const threadSearchIndex = useGlobalThreadSearchIndex(); React.useEffect(() => { void (async () => { const results = threadSearchIndex.getSearchResults(searchText); setThreadSearchResults(new Set(results)); const usersResults = await searchUsers(forwardLookupSearchText); setUsersSearchResults(usersResults); })(); }, [searchText, forwardLookupSearchText, threadSearchIndex, searchUsers]); return { threadSearchResults, usersSearchResults }; } function removeMemberFromThread( threadInfo: ThreadInfo, memberInfo: RelativeMemberInfo, dispatchActionPromise: DispatchActionPromise, removeUserFromThreadServerCall: ( input: RemoveUsersFromThreadInput, ) => Promise, ) { const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`; void dispatchActionPromise( removeUsersFromThreadActionTypes, removeUserFromThreadServerCall({ threadID: threadInfo.id, memberIDs: [memberInfo.id], }), { customKeyName }, ); } function getAvailableThreadMemberActions( memberInfo: RelativeMemberInfo, threadInfo: ThreadInfo, canEdit: ?boolean = true, ): $ReadOnlyArray<'change_role' | 'remove_user'> { const role = memberInfo.role; if (!canEdit || !role) { return []; } const canRemoveMembers = threadHasPermission( threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = threadHasPermission( threadInfo, threadPermissions.CHANGE_ROLE, ); const result = []; if (canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo)) { result.push('change_role'); } if ( canRemoveMembers && !memberInfo.isViewer && (canChangeRoles || roleIsDefaultRole(threadInfo.roles[role])) ) { result.push('remove_user'); } return result; } function patchThreadInfoToIncludeMentionedMembersOfParent( threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, messageText: string, viewerID: string, ): ThreadInfo { const members: UserIDAndUsername[] = threadInfo.members .map(({ id, username }) => username ? ({ id, username }: UserIDAndUsername) : null, ) .filter(Boolean); const mentionedNewMembers = extractNewMentionedParentMembers( messageText, threadInfo, parentThreadInfo, ); if (mentionedNewMembers.length === 0) { return threadInfo; } members.push(...mentionedNewMembers); return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members, parentThreadInfo, threadColor: threadInfo.color, name: threadInfo.name, sourceMessageID: threadInfo.sourceMessageID, }); } function threadInfoInsideCommunity( threadInfo: RawThreadInfo | ThreadInfo, communityID: string, ): boolean { return threadInfo.community === communityID || threadInfo.id === communityID; } type RoleAndMemberCount = { [roleName: string]: number, }; function useRoleMemberCountsForCommunity( threadInfo: ThreadInfo, ): RoleAndMemberCount { return React.useMemo(() => { const roleIDsToNames: { [string]: string } = {}; Object.keys(threadInfo.roles).forEach(roleID => { roleIDsToNames[roleID] = threadInfo.roles[roleID].name; }); const roleNamesToMemberCount: RoleAndMemberCount = {}; threadInfo.members.forEach(({ role: roleID }) => { invariant(roleID, 'Community member should have a role'); const roleName = roleIDsToNames[roleID]; roleNamesToMemberCount[roleName] = (roleNamesToMemberCount[roleName] ?? 0) + 1; }); // For all community roles with no members, add them to the list with 0 Object.keys(roleIDsToNames).forEach(roleName => { if (roleNamesToMemberCount[roleIDsToNames[roleName]] === undefined) { roleNamesToMemberCount[roleIDsToNames[roleName]] = 0; } }); return roleNamesToMemberCount; }, [threadInfo]); } type RoleUserSurfacedPermissions = { +[roleName: string]: $ReadOnlySet, }; // Iterates through the existing roles in the community and 'reverse maps' // the set of permission literals for each role to user-facing permission enums // to help pre-populate the permission checkboxes when editing roles. function useRoleUserSurfacedPermissions( threadInfo: ThreadInfo, ): RoleUserSurfacedPermissions { return React.useMemo(() => { const roleNamesToPermissions: { [string]: Set } = {}; Object.keys(threadInfo.roles).forEach(roleID => { const roleName = threadInfo.roles[roleID].name; const rolePermissions = Object.keys( decodeMinimallyEncodedRoleInfo(threadInfo.roles[roleID]).permissions, ); const setOfUserSurfacedPermissions = new Set(); rolePermissions.forEach(rolePermission => { const userSurfacedPermission = Object.keys( configurableCommunityPermissions, ).find(key => configurableCommunityPermissions[key].has(rolePermission), ); if (userSurfacedPermission) { setOfUserSurfacedPermissions.add(userSurfacedPermission); } }); roleNamesToPermissions[roleName] = setOfUserSurfacedPermissions; }); return roleNamesToPermissions; }, [threadInfo]); } function communityOrThreadNoun(threadInfo: RawThreadInfo | ThreadInfo): string { return threadTypeIsCommunityRoot(threadInfo.type) ? 'community' : threadNoun(threadInfo.type, threadInfo.parentThreadID); } function getThreadsToDeleteText( threadInfo: RawThreadInfo | ThreadInfo, ): string { return `${ threadTypeIsCommunityRoot(threadInfo.type) ? 'Subchannels and threads' : 'Threads' } within this ${communityOrThreadNoun(threadInfo)}`; } function useUserProfileThreadInfo(userInfo: ?UserInfo): ?UserProfileThreadInfo { const userID = userInfo?.id; const username = userInfo?.username; const loggedInUserInfo = useLoggedInUserInfo(); const isViewerProfile = loggedInUserInfo?.id === userID; const privateThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PRIVATE, ); const privateThreadInfos = useSelector(privateThreadInfosSelector); const personalThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PERSONAL, ); const personalThreadInfos = useSelector(personalThreadInfosSelector); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); return React.useMemo(() => { if (!loggedInUserInfo || !userID || !username) { return null; } if (isViewerProfile) { const privateThreadInfo: ?ThreadInfo = privateThreadInfos[0]; return privateThreadInfo ? { threadInfo: privateThreadInfo } : null; } if (usersWithPersonalThread.has(userID)) { const personalThreadInfo: ?ThreadInfo = personalThreadInfos.find( threadInfo => userID === getSingleOtherUser(threadInfo, loggedInUserInfo.id), ); return personalThreadInfo ? { threadInfo: personalThreadInfo } : null; } const pendingPersonalThreadInfo = createPendingPersonalThread( loggedInUserInfo, userID, username, ); return pendingPersonalThreadInfo; }, [ isViewerProfile, loggedInUserInfo, personalThreadInfos, privateThreadInfos, userID, username, usersWithPersonalThread, ]); } function assertAllThreadInfosAreLegacy(rawThreadInfos: MixedRawThreadInfos): { [id: string]: LegacyRawThreadInfo, } { return Object.fromEntries( Object.entries(rawThreadInfos).map(([id, rawThreadInfo]) => { invariant( !rawThreadInfo.minimallyEncoded, `rawThreadInfos shouldn't be minimallyEncoded`, ); return [id, rawThreadInfo]; }), ); } export { threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadIsChannel, threadIsSidebar, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadOtherMembers, threadIsGroupChat, threadIsPending, threadIsPendingSidebar, getSingleOtherUser, getPendingThreadID, parsePendingThreadID, createPendingThread, createUnresolvedPendingSidebar, extractNewMentionedParentMembers, createPendingSidebar, pendingThreadType, createRealThreadFromPendingThread, getMinimallyEncodedCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, threadUIName, threadInfoFromRawThreadInfo, threadTypeDescriptions, memberHasAdminPowers, roleIsDefaultRole, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadNoun, threadLabel, useWatchThread, useExistingThreadInfoFinder, getThreadTypeParentRequirement, threadMemberHasPermission, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, checkIfDefaultMembersAreVoiced, draftKeySuffix, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, useThreadListSearch, removeMemberFromThread, getAvailableThreadMemberActions, threadMembersWithoutAddedAshoat, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, useRoleMemberCountsForCommunity, useRoleUserSurfacedPermissions, getThreadsToDeleteText, useUserProfileThreadInfo, assertAllThreadInfosAreLegacy, }; diff --git a/native/chat/message-list-container.react.js b/native/chat/message-list-container.react.js index 8cbe7aead..bbe13926d 100644 --- a/native/chat/message-list-container.react.js +++ b/native/chat/message-list-container.react.js @@ -1,430 +1,430 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome5.js'; import { useNavigationState } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import genesis from 'lib/facts/genesis.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { usePotentialMemberItems, useSearchUsers, } from 'lib/shared/search-utils.js'; import { pendingThreadType, useExistingThreadInfoFinder, } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js'; import { pinnedMessageCountText } from 'lib/utils/message-pinning-utils.js'; import { type MessagesMeasurer, useHeightMeasurer } from './chat-context.js'; import { ChatInputBar } from './chat-input-bar.react.js'; import type { ChatNavigationProp } from './chat.react.js'; import { type NativeChatMessageItem, useNativeMessageListData, } from './message-data.react.js'; import MessageListThreadSearch from './message-list-thread-search.react.js'; import { MessageListContextProvider } from './message-list-types.js'; import MessageList from './message-list.react.js'; import ParentThreadHeader from './parent-thread-header.react.js'; import ContentLoading from '../components/content-loading.react.js'; import { InputStateContext } from '../input/input-state.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { PinnedMessagesScreenRouteName, ThreadSettingsRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; const unboundStyles = { pinnedCountBanner: { backgroundColor: 'panelForeground', height: 30, flexDirection: 'row', textAlign: 'center', justifyContent: 'center', alignItems: 'center', }, pinnedCountText: { color: 'panelBackgroundLabel', marginRight: 5, }, container: { backgroundColor: 'listBackground', flex: 1, }, threadContent: { flex: 1, }, hiddenThreadContent: { height: 0, opacity: 0, }, }; type BaseProps = { +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; type Props = { ...BaseProps, // Redux state +usernameInputText: string, +updateUsernameInput: (text: string) => void, +userInfoInputArray: $ReadOnlyArray, +updateTagInput: (items: $ReadOnlyArray) => void, +resolveToUser: (user: AccountUserInfo) => void, +userSearchResults: $ReadOnlyArray, +threadInfo: ThreadInfo, +genesisThreadInfo: ?ThreadInfo, +messageListData: ?$ReadOnlyArray, +colors: Colors, +styles: $ReadOnly, // withOverlayContext +overlayContext: ?OverlayContextType, +measureMessages: MessagesMeasurer, }; type State = { +listDataWithHeights: ?$ReadOnlyArray, }; class MessageListContainer extends React.PureComponent { state: State = { listDataWithHeights: null, }; pendingListDataWithHeights: ?$ReadOnlyArray; get frozen(): boolean { const { overlayContext } = this.props; invariant( overlayContext, 'MessageListContainer should have OverlayContext', ); return overlayContext.scrollBlockingModalStatus !== 'closed'; } setListData = ( listDataWithHeights: $ReadOnlyArray, ) => { this.setState({ listDataWithHeights }); }; componentDidMount() { this.props.measureMessages( this.props.messageListData, this.props.threadInfo, this.setListData, ); } componentDidUpdate(prevProps: Props) { const oldListData = prevProps.messageListData; const newListData = this.props.messageListData; if (!newListData && oldListData) { this.setState({ listDataWithHeights: null }); } if ( oldListData !== newListData || prevProps.threadInfo !== this.props.threadInfo || prevProps.measureMessages !== this.props.measureMessages ) { this.props.measureMessages( newListData, this.props.threadInfo, this.allHeightsMeasured, ); } if (!this.frozen && this.pendingListDataWithHeights) { this.setState({ listDataWithHeights: this.pendingListDataWithHeights }); this.pendingListDataWithHeights = undefined; } } render(): React.Node { const { threadInfo, styles } = this.props; const { listDataWithHeights } = this.state; const { searching } = this.props.route.params; let searchComponent = null; if (searching) { const { userInfoInputArray, genesisThreadInfo } = this.props; // It's technically possible for the client to be missing the Genesis // ThreadInfo when it first opens up (before the server delivers it) let parentThreadHeader; if (genesisThreadInfo) { parentThreadHeader = ( ); } searchComponent = ( <> {parentThreadHeader} ); } const showMessageList = !searching || this.props.userInfoInputArray.length > 0; let messageList; if (showMessageList && listDataWithHeights) { messageList = ( ); } else if (showMessageList) { messageList = ( ); } const threadContentStyles = showMessageList ? [styles.threadContent] : [styles.hiddenThreadContent]; const pointerEvents = showMessageList ? 'auto' : 'none'; const threadContent = ( {messageList} ); return ( {searchComponent} {threadContent} ); } allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { if (this.frozen) { this.pendingListDataWithHeights = listDataWithHeights; } else { this.setState({ listDataWithHeights }); } }; } const ConnectedMessageListContainer: React.ComponentType = React.memo(function ConnectedMessageListContainer( props: BaseProps, ) { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const serverSearchResults = useSearchUsers(usernameInputText); const userSearchResults = usePotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, excludeUserIDs: userInfoInputArray.map(userInfo => userInfo.id), includeServerSearchUsers: serverSearchResults, }); const [baseThreadInfo, setBaseThreadInfo] = React.useState( props.route.params.threadInfo, ); const existingThreadInfoFinder = useExistingThreadInfoFinder(baseThreadInfo); const isSearching = !!props.route.params.searching; const threadInfo = React.useMemo( () => existingThreadInfoFinder({ searching: isSearching, userInfoInputArray, }), [existingThreadInfoFinder, isSearching, userInfoInputArray], ); invariant( threadInfo, 'threadInfo must be specified in messageListContainer', ); const inputState = React.useContext(InputStateContext); invariant(inputState, 'inputState should be set in MessageListContainer'); const isFocused = props.navigation.isFocused(); const { setPendingThreadUpdateHandler } = inputState; React.useEffect(() => { if (!isFocused) { return undefined; } setPendingThreadUpdateHandler(threadInfo.id, setBaseThreadInfo); return () => { setPendingThreadUpdateHandler(threadInfo.id, undefined); }; }, [setPendingThreadUpdateHandler, isFocused, threadInfo.id]); const { setParams } = props.navigation; const navigationStack = useNavigationState(state => state.routes); React.useEffect(() => { const topRoute = navigationStack[navigationStack.length - 1]; if (topRoute?.name !== ThreadSettingsRouteName) { return; } setBaseThreadInfo(threadInfo); if (isSearching) { setParams({ searching: false }); } }, [isSearching, navigationStack, setParams, threadInfo]); const hideSearch = React.useCallback(() => { setBaseThreadInfo(threadInfo); setParams({ searching: false }); }, [setParams, threadInfo]); React.useEffect(() => { if (!isSearching) { return undefined; } inputState.registerSendCallback(hideSearch); return () => inputState.unregisterSendCallback(hideSearch); }, [hideSearch, inputState, isSearching]); React.useEffect(() => { setParams({ threadInfo }); }, [setParams, threadInfo]); const updateTagInput = React.useCallback( (input: $ReadOnlyArray) => setUserInfoInputArray(input), [], ); const updateUsernameInput = React.useCallback( (text: string) => setUsernameInputText(text), [], ); const { editInputMessage } = inputState; const resolveToUser = React.useCallback( (user: AccountUserInfo) => { const resolvedThreadInfo = existingThreadInfoFinder({ searching: true, userInfoInputArray: [user], }); invariant( resolvedThreadInfo, 'resolvedThreadInfo must be specified in messageListContainer', ); editInputMessage({ message: '', mode: 'prepend' }); setBaseThreadInfo(resolvedThreadInfo); setParams({ searching: false, threadInfo: resolvedThreadInfo }); }, [existingThreadInfoFinder, editInputMessage, setParams], ); const messageListData = useNativeMessageListData({ searching: isSearching, userInfoInputArray, threadInfo, }); const colors = useColors(); const styles = useStyles(unboundStyles); const overlayContext = React.useContext(OverlayContext); const measureMessages = useHeightMeasurer(); const genesisThreadInfo = useSelector( - state => threadInfoSelector(state)[genesis.id], + state => threadInfoSelector(state)[genesis().id], ); const bannerText = !!threadInfo.pinnedCount && pinnedMessageCountText(threadInfo.pinnedCount); const navigateToMessageResults = React.useCallback(() => { props.navigation.navigate<'PinnedMessagesScreen'>({ name: PinnedMessagesScreenRouteName, params: { threadInfo, }, key: `PinnedMessagesScreen${threadInfo.id}`, }); }, [props.navigation, threadInfo]); const pinnedCountBanner = React.useMemo(() => { if (!bannerText) { return null; } return ( {bannerText} ); }, [ navigateToMessageResults, bannerText, styles.pinnedCountBanner, styles.pinnedCountText, colors.panelBackgroundLabel, ]); return ( {pinnedCountBanner} ); }); export default ConnectedMessageListContainer; diff --git a/web/chat/chat-thread-list.react.js b/web/chat/chat-thread-list.react.js index 97d3d0d59..ace293138 100644 --- a/web/chat/chat-thread-list.react.js +++ b/web/chat/chat-thread-list.react.js @@ -1,198 +1,198 @@ // @flow import invariant from 'invariant'; import _sum from 'lodash/fp/sum.js'; import * as React from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { VariableSizeList } from 'react-window'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import genesis from 'lib/facts/genesis.js'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { emptyItemText } from 'lib/shared/thread-utils.js'; import ChatThreadListItem from './chat-thread-list-item.react.js'; import ChatThreadListSearch from './chat-thread-list-search.react.js'; import css from './chat-thread-list.css'; import { ThreadListContext } from './thread-list-provider.js'; import BackgroundIllustration from '../assets/background-illustration.react.js'; import Button from '../components/button.react.js'; import ComposeSubchannelModal from '../modals/threads/create/compose-subchannel-modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useOnClickNewThread } from '../selectors/thread-selectors.js'; type Item = ChatThreadItem | { +type: 'search' } | { +type: 'empty' }; const sizes = { search: 68, empty: 249, thread: 81, sidebars: { sidebar: 32, seeMore: 22, spacer: 6 }, }; const itemKey = (index: number, data: $ReadOnlyArray) => { if (data[index].type === 'search') { return 'search'; } else if (data[index].type === 'empty') { return 'empty'; } else { return data[index].threadInfo.id; } }; type RenderItemInput = { +index: number, +data: $ReadOnlyArray, +style: CSSStyleDeclaration, }; const renderItem: RenderItemInput => React.Node = ({ index, data, style }) => { let item; if (data[index].type === 'search') { item = ; } else if (data[index].type === 'empty') { item = ; } else { item = ; } return
{item}
; }; type VariableSizeListRef = { +resetAfterIndex: (index: number, shouldForceUpdate?: boolean) => void, ... }; function ChatThreadList(): React.Node { const threadListContext = React.useContext(ThreadListContext); invariant( threadListContext, 'threadListContext should be set in ChatThreadList', ); const { activeTab, threadList } = threadListContext; const onClickNewThread = useOnClickNewThread(); const isThreadCreation = useSelector( state => state.navInfo.chatMode === 'create', ); const isBackground = activeTab === 'Background'; const communityID = useSelector(state => state.communityPickerStore.chat); const communityThreadInfo = useSelector(state => { if (!communityID) { return null; } return threadInfoSelector(state)[communityID]; }); const { pushModal, popModal } = useModalContext(); const onClickCreateSubchannel = React.useCallback(() => { if (!communityThreadInfo) { return null; } return pushModal( , ); }, [popModal, pushModal, communityThreadInfo]); - const isChatCreation = !communityID || communityID === genesis.id; + const isChatCreation = !communityID || communityID === genesis().id; const onClickCreate = isChatCreation ? onClickNewThread : onClickCreateSubchannel; const createButtonText = isChatCreation ? 'Create new chat' : 'Create new channel'; const threadListContainerRef = React.useRef(); const threads = React.useMemo( () => threadList.filter( item => !communityID || item.threadInfo.community === communityID || item.threadInfo.id === communityID, ), [communityID, threadList], ); React.useEffect(() => { if (threadListContainerRef.current) { threadListContainerRef.current.resetAfterIndex(0, false); } }, [threads]); const threadListContainer = React.useMemo(() => { const items: Item[] = [{ type: 'search' }, ...threads]; if (isBackground && threads.length === 0) { items.push({ type: 'empty' }); } const itemSize = (index: number) => { if (items[index].type === 'search') { return sizes.search; } else if (items[index].type === 'empty') { return sizes.empty; } const sidebarHeight = _sum( items[index].sidebars.map(s => sizes.sidebars[s.type]), ); return sizes.thread + sidebarHeight; }; return ( {({ height }) => ( {renderItem} )} ); }, [isBackground, threads]); return ( <>
{threadListContainer}
); } function EmptyItem() { return (
{emptyItemText}
); } export default ChatThreadList;