diff --git a/keyserver/src/bots/commbot.js b/keyserver/src/bots/commbot.js index af9061a58..3a3c34dce 100644 --- a/keyserver/src/bots/commbot.js +++ b/keyserver/src/bots/commbot.js @@ -1,30 +1,30 @@ // @flow import invariant from 'invariant'; import bots from 'lib/facts/bots.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { createThread } from '../creators/thread-creator.js'; import { createBotViewer } from '../session/bots.js'; const { commbot } = bots; async function createCommbotThread(userID: string): Promise { const commbotViewer = createBotViewer(commbot.userID); const newThreadRequest = { - type: threadTypes.PERSONAL, + type: threadTypes.GENESIS_PERSONAL, initialMemberIDs: [userID], }; const result = await createThread(commbotViewer, newThreadRequest, { forceAddMembers: true, }); const { newThreadID } = result; invariant( newThreadID, 'createThread should return newThreadID to bot viewer', ); return newThreadID; } export { createCommbotThread }; diff --git a/keyserver/src/creators/account-creator.js b/keyserver/src/creators/account-creator.js index d51449fb4..31820de60 100644 --- a/keyserver/src/creators/account-creator.js +++ b/keyserver/src/creators/account-creator.js @@ -1,261 +1,261 @@ // @flow import { getRustAPI } from 'rust-node-addon'; import bcrypt from 'twin-bcrypt'; 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, } from 'lib/types/crypto-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { RawMessageInfo } from 'lib/types/message-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 { persistFreshOlmSession, createOlmSession, } 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'; import { isAuthoritativeKeyserver, thisKeyserverAdmin, } from '../user/identity.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 || isValidEthereumAddress(request.username.toLowerCase()) ) { 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())) { throw new ServerError('username_reserved'); } if (existingUser) { throw new ServerError('username_taken'); } // Olm sessions have to be created before createNewUserCookie is called, // to avoid propagating a user cookie in case session creation fails const olmNotifSession = await (async () => { if (initialNotificationsEncryptedMessage) { return await createOlmSession( initialNotificationsEncryptedMessage, 'notifications', ); } return null; })(); 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 persistOlmNotifSessionPromise = (async () => { if (olmNotifSession && userViewerData.cookieID) { await persistFreshOlmSession( olmNotifSession, 'notifications', userViewerData.cookieID, ); } })(); await Promise.all([ viewerAcknowledgmentUpdater(viewer, policyTypes.tosAndPrivacyPolicy), persistOlmNotifSessionPromise, ]); const rawMessageInfos = await sendMessagesOnAccountCreation(viewer); const [threadsResult, userInfos, currentUserInfo] = await Promise.all([ fetchThreadInfos(viewer), fetchKnownUserInfos(viewer), fetchLoggedInUserInfo(viewer), ]); ignorePromiseRejections( createAndSendReservedUsernameMessage([ { username: request.username, userID: id }, ]), ); return { id, rawMessageInfos, currentUserInfo, cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: values(userInfos), }, }; } async function sendMessagesOnAccountCreation( viewer: Viewer, ): Promise { const isAuthoritative = await isAuthoritativeKeyserver(); if (!isAuthoritative) { return []; } const admin = await thisKeyserverAdmin(); await updateThread( createScriptViewer(admin.id), { threadID: genesis().id, changes: { newMemberIDs: [viewer.userID] }, }, { forceAddMembers: true, silenceMessages: true, ignorePermissions: true }, ); const [privateThreadResult, ashoatThreadResult] = await Promise.all([ createPrivateThread(viewer), createThread( viewer, { - type: threadTypes.PERSONAL, + type: threadTypes.GENESIS_PERSONAL, initialMemberIDs: [admin.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: admin.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 = await createMessages(viewer, messageDatas); return [ ...ashoatThreadResult.newMessageInfos, ...privateThreadResult.newMessageInfos, ...messageInfos, ]; } 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, sendMessagesOnAccountCreation, createAndSendReservedUsernameMessage, }; diff --git a/keyserver/src/creators/thread-creator.js b/keyserver/src/creators/thread-creator.js index 43ef16d8e..87d161248 100644 --- a/keyserver/src/creators/thread-creator.js +++ b/keyserver/src/creators/thread-creator.js @@ -1,528 +1,528 @@ // @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 { isAuthoritativeKeyserver } from '../user/identity.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; + forceAddMembers || threadType === threadTypes.GENESIS_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 && + threadType === threadTypes.GENESIS_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)) { const isAuthoritative = await isAuthoritativeKeyserver(); if (!isAuthoritative) { throw new ServerError('invalid_parameters'); } 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) { + if (threadType === threadTypes.GENESIS_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) { + if (threadType === threadTypes.GENESIS_PERSONAL) { const otherMemberID = initialMemberIDs?.[0]; invariant( otherMemberID, - 'Other member id should be set for a PERSONAL thread', + 'Other member id should be set for a GENESIS_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) { + if (threadType !== threadTypes.GENESIS_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` START TRANSACTION; INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, source_message) VALUES ${[row]}; `; if (threadTypeIsCommunityRoot(threadType)) { query.append(SQL` INSERT INTO communities (id) VALUES (${id}); `); } query.append(SQL` COMMIT; `); await dbQuery(query, { multipleStatements: true }); } 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 || 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, + type: threadTypes.GENESIS_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 2f8183383..5696c21d5 100644 --- a/keyserver/src/database/migration-config.js +++ b/keyserver/src/database/migration-config.js @@ -1,974 +1,974 @@ // @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 type { PickledOlmAccount } 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} + WHERE t.type = ${threadTypes.GENESIS_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} 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 } AND stm.thread = m.content WHERE JSON_EXTRACT(stm.permissions, ${visibleExtractString}) IS TRUE 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}; `; 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], [ 58, async () => { await dbQuery( SQL` ALTER TABLE updates MODIFY \`key\` varchar(255) CHARSET latin1 COLLATE latin1_bin `, ); }, ], [59, () => dbQuery(SQL`DROP TABLE one_time_keys`)], [ 60, async () => { await dbQuery( SQL` DELETE FROM messages WHERE type = 22 AND JSON_EXTRACT(content, '$.operation') = 'farcaster_mutual' `, ); }, ], [ 61, async () => { await dbQuery( SQL` ALTER TABLE uploads MODIFY container varchar(255) CHARSET latin1 COLLATE latin1_bin `, ); }, ], [ 62, async () => { await dbQuery( SQL` ALTER TABLE uploads MODIFY container bigint(20) `, ); }, ], [ 63, async () => { await dbQuery( SQL` ALTER TABLE uploads ADD COLUMN user_container varchar(255) CHARSET latin1 COLLATE latin1_bin DEFAULT NULL AFTER container, ADD INDEX user_container (user_container); UPDATE IGNORE uploads u INNER JOIN users us ON us.id = u.container SET u.container = NULL, u.user_container = us.id; `, { multipleStatements: true }, ); }, ], [ 64, async () => { await dbQuery( SQL` CREATE TABLE IF NOT EXISTS communities ( id bigint(20) NOT NULL, farcaster_channel_id varchar(255) CHARSET latin1 DEFAULT NULL, blob_holder char(36) CHARSET latin1 DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; ALTER TABLE communities ADD PRIMARY KEY (id); INSERT INTO communities (id) SELECT id FROM threads t WHERE t.depth = 0; `, { multipleStatements: true }, ); }, ], [ 65, () => dbQuery(SQL` ALTER TABLE invite_links ADD COLUMN IF NOT EXISTS thread bigint(20) DEFAULT NULL, ADD COLUMN IF NOT EXISTS thread_role bigint(20) DEFAULT NULL, ADD INDEX IF NOT EXISTS community_thread (community, thread); `), ], [66, updateRolesAndPermissionsForAllThreads], ]); 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 saveNewOlmAccounts(pickledContentAccount, pickledNotificationsAccount); } async function saveNewOlmAccounts( pickledContentAccount: PickledOlmAccount, pickledNotificationsAccount: PickledOlmAccount, ) { 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, saveNewOlmAccounts, }; diff --git a/keyserver/src/fetchers/thread-fetchers.js b/keyserver/src/fetchers/thread-fetchers.js index 9ba8f49ec..aff0da67c 100644 --- a/keyserver/src/fetchers/thread-fetchers.js +++ b/keyserver/src/fetchers/thread-fetchers.js @@ -1,448 +1,448 @@ // @flow import invariant from 'invariant'; import { specialRoles } from 'lib/permissions/special-roles.js'; import { getAllThreadPermissions } from 'lib/permissions/thread-permissions.js'; import { rawThreadInfoFromServerThreadInfo, getContainingThreadID, getCommunity, } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { AvatarDBContent, ClientAvatar } from 'lib/types/avatar-types.js'; import type { RawMessageInfo, MessageInfo } from 'lib/types/message-types.js'; import type { RawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes, type ThreadType } from 'lib/types/thread-types-enum.js'; import { type ServerThreadInfo, type MixedRawThreadInfos, type LegacyRawThreadInfo, type ServerLegacyRoleInfo, } from 'lib/types/thread-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { getUploadURL, makeUploadURI } from './upload-fetchers.js'; import { dbQuery, SQL, mergeAndConditions } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import type { Viewer } from '../session/viewer.js'; type FetchThreadInfosFilter = Partial<{ +accessibleToUserID: string, +threadID: string, +threadIDs: $ReadOnlySet, +parentThreadID: string, +sourceMessageID: string, }>; function constructWhereClause( filter: FetchThreadInfosFilter, ): SQLStatementType { const fromTable = filter.accessibleToUserID ? 'memberships' : 'threads'; const conditions = []; if (filter.accessibleToUserID) { conditions.push( SQL`mm.user = ${filter.accessibleToUserID} AND mm.role > -1`, ); } if (filter.threadID && fromTable === 'memberships') { conditions.push(SQL`mm.thread = ${filter.threadID}`); } else if (filter.threadID) { conditions.push(SQL`t.id = ${filter.threadID}`); } if (filter.threadIDs && fromTable === 'memberships') { conditions.push(SQL`mm.thread IN (${[...filter.threadIDs]})`); } else if (filter.threadIDs) { conditions.push(SQL`t.id IN (${[...filter.threadIDs]})`); } if (filter.parentThreadID) { conditions.push(SQL`t.parent_thread_id = ${filter.parentThreadID}`); } if (filter.sourceMessageID) { conditions.push(SQL`t.source_message = ${filter.sourceMessageID}`); } if (conditions.length === 0) { return SQL``; } const clause = mergeAndConditions(conditions); return SQL`WHERE `.append(clause); } type FetchServerThreadInfosResult = { +threadInfos: { +[id: string]: ServerThreadInfo }, }; async function fetchServerThreadInfos( filter?: FetchThreadInfosFilter, ): Promise { if (filter?.threadIDs?.size === 0) { return { threadInfos: {} }; } let primaryFetchClause; if (filter?.accessibleToUserID) { primaryFetchClause = SQL` FROM memberships mm LEFT JOIN threads t ON t.id = mm.thread `; } else { primaryFetchClause = SQL` FROM threads t `; } const whereClause = filter ? constructWhereClause(filter) : ''; const rolesQuery = SQL` SELECT t.id, r.id AS role, r.name, r.permissions, r.special_role, r.special_role = ${specialRoles.DEFAULT_ROLE} AS is_default ` .append(primaryFetchClause) .append( SQL` LEFT JOIN roles r ON r.thread = t.id `, ) .append(whereClause); const threadsQuery = SQL` SELECT t.id, t.name, t.parent_thread_id, t.containing_thread_id, t.community, t.depth, t.color, t.description, t.type, t.creation_time, t.source_message, t.replies_count, t.avatar, t.pinned_count, m.user, m.role, m.permissions, m.subscription, m.last_read_message < m.last_message AS unread, m.sender, up.id AS upload_id, up.secret AS upload_secret, up.extra AS upload_extra ` .append(primaryFetchClause) .append( SQL` LEFT JOIN memberships m ON m.thread = t.id AND m.role >= 0 LEFT JOIN uploads up ON up.container = t.id `, ) .append(whereClause) .append(SQL` ORDER BY m.user ASC`); const [[threadsResult], [rolesResult]] = await Promise.all([ dbQuery(threadsQuery), dbQuery(rolesQuery), ]); const threadInfos = {}; for (const threadsRow of threadsResult) { const threadID = threadsRow.id.toString(); if (!threadInfos[threadID]) { threadInfos[threadID] = { id: threadID, type: threadsRow.type, name: threadsRow.name ? threadsRow.name : '', description: threadsRow.description ? threadsRow.description : '', color: threadsRow.color, creationTime: threadsRow.creation_time, parentThreadID: threadsRow.parent_thread_id ? threadsRow.parent_thread_id.toString() : null, containingThreadID: threadsRow.containing_thread_id ? threadsRow.containing_thread_id.toString() : null, depth: threadsRow.depth, community: threadsRow.community ? threadsRow.community.toString() : null, members: [], roles: {}, repliesCount: threadsRow.replies_count, pinnedCount: threadsRow.pinned_count, }; if (threadsRow.avatar) { const avatar: AvatarDBContent = JSON.parse(threadsRow.avatar); let clientAvatar: ?ClientAvatar; if ( avatar && avatar.type !== 'image' && avatar.type !== 'encrypted_image' ) { clientAvatar = avatar; } else if ( avatar && (avatar.type === 'image' || avatar.type === 'encrypted_image') && threadsRow.upload_id && threadsRow.upload_secret ) { const uploadID = threadsRow.upload_id.toString(); invariant( uploadID === avatar.uploadID, `uploadID of upload should match uploadID of image avatar`, ); if (avatar.type === 'encrypted_image' && threadsRow.upload_extra) { const uploadExtra = JSON.parse(threadsRow.upload_extra); clientAvatar = { type: 'encrypted_image', blobURI: makeUploadURI( uploadExtra.blobHash, uploadID, threadsRow.upload_secret, ), encryptionKey: uploadExtra.encryptionKey, thumbHash: uploadExtra.thumbHash, }; } else { clientAvatar = { type: 'image', uri: getUploadURL(uploadID, threadsRow.upload_secret), }; } } threadInfos[threadID] = { ...threadInfos[threadID], avatar: clientAvatar, }; } } const sourceMessageID = threadsRow.source_message?.toString(); if (sourceMessageID) { threadInfos[threadID].sourceMessageID = sourceMessageID; } if (threadsRow.user) { const userID = threadsRow.user.toString(); const allPermissions = getAllThreadPermissions( JSON.parse(threadsRow.permissions), threadID, ); threadInfos[threadID].members.push({ id: userID, permissions: allPermissions, role: threadsRow.role ? threadsRow.role.toString() : null, subscription: JSON.parse(threadsRow.subscription), unread: threadsRow.role ? !!threadsRow.unread : null, isSender: !!threadsRow.sender, }); } } for (const rolesRow of rolesResult) { const threadID = rolesRow.id.toString(); if (!rolesRow.role) { continue; } const role = rolesRow.role.toString(); if (!threadInfos[threadID].roles[role]) { const roleInfo: ServerLegacyRoleInfo = { id: role, name: rolesRow.name, permissions: JSON.parse(rolesRow.permissions), isDefault: Boolean(rolesRow.is_default), specialRole: rolesRow.special_role, }; threadInfos[threadID].roles[role] = roleInfo; } } return { threadInfos }; } export type FetchThreadInfosResult = { +threadInfos: MixedRawThreadInfos, }; async function fetchThreadInfos( viewer: Viewer, inputFilter?: FetchThreadInfosFilter, ): Promise { const filter = { accessibleToUserID: viewer.id, ...inputFilter, }; const serverResult = await fetchServerThreadInfos(filter); return rawThreadInfosFromServerThreadInfos(viewer, serverResult); } function rawThreadInfosFromServerThreadInfos( viewer: Viewer, serverResult: FetchServerThreadInfosResult, ): FetchThreadInfosResult { const viewerID = viewer.id; const codeVersionBelow209 = !hasMinCodeVersion(viewer.platformDetails, { native: 209, }); const codeVersionBelow213 = !hasMinCodeVersion(viewer.platformDetails, { native: 213, }); const codeVersionBelow221 = !hasMinCodeVersion(viewer.platformDetails, { native: 221, }); const codeVersionBelow283 = !hasMinCodeVersion(viewer.platformDetails, { native: 285, }); const minimallyEncodedPermissionsSupported = hasMinCodeVersion( viewer.platformDetails, { native: 301, web: 56 }, ); const specialRoleFieldSupported = hasMinCodeVersion(viewer.platformDetails, { native: 336, web: 79, }); const addingUsersToCommunityRootSupported = !hasMinCodeVersion( viewer.platformDetails, { native: 355, web: 88, }, ); const manageFarcasterChannelTagsPermissionUnsupported = !hasMinCodeVersion( viewer.platformDetails, { native: 355, web: 88, }, ); const threadInfos: { [string]: LegacyRawThreadInfo | RawThreadInfo, } = {}; for (const threadID in serverResult.threadInfos) { const serverThreadInfo = serverResult.threadInfos[threadID]; const threadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, viewerID, { filterThreadEditAvatarPermission: codeVersionBelow213, excludePinInfo: codeVersionBelow209, filterManageInviteLinksPermission: codeVersionBelow221, filterVoicedInAnnouncementChannelsPermission: codeVersionBelow283, minimallyEncodePermissions: minimallyEncodedPermissionsSupported, includeSpecialRoleFieldInRoles: specialRoleFieldSupported, allowAddingUsersToCommunityRoot: addingUsersToCommunityRootSupported, filterManageFarcasterChannelTagsPermission: manageFarcasterChannelTagsPermissionUnsupported, }, ); if (threadInfo) { threadInfos[threadID] = threadInfo; } } return { threadInfos }; } async function verifyThreadIDs( threadIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { if (threadIDs.length === 0) { return []; } const query = SQL`SELECT id FROM threads WHERE id IN (${threadIDs})`; const [result] = await dbQuery(query); const verified = []; for (const row of result) { verified.push(row.id.toString()); } return verified; } async function verifyThreadID(threadID: string): Promise { const result = await verifyThreadIDs([threadID]); return result.length !== 0; } type ThreadAncestry = { +containingThreadID: ?string, +community: ?string, +depth: number, }; async function determineThreadAncestry( parentThreadID: ?string, threadType: ThreadType, ): Promise { if (!parentThreadID) { return { containingThreadID: null, community: null, depth: 0 }; } const parentThreadInfos = await fetchServerThreadInfos({ threadID: parentThreadID, }); const parentThreadInfo = parentThreadInfos.threadInfos[parentThreadID]; if (!parentThreadInfo) { throw new ServerError('invalid_parameters'); } const containingThreadID = getContainingThreadID( parentThreadInfo, threadType, ); const community = getCommunity(parentThreadInfo); const depth = parentThreadInfo.depth + 1; return { containingThreadID, community, depth }; } function personalThreadQuery( firstMemberID: string, secondMemberID: string, ): SQLStatementType { return SQL` SELECT t.id FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id AND m1.user = ${firstMemberID} INNER JOIN memberships m2 ON m2.thread = t.id AND m2.user = ${secondMemberID} - WHERE t.type = ${threadTypes.PERSONAL} + WHERE t.type = ${threadTypes.GENESIS_PERSONAL} AND m1.role > 0 AND m2.role > 0 `; } async function fetchPersonalThreadID( viewerID: string, otherMemberID: string, ): Promise { const query = personalThreadQuery(viewerID, otherMemberID); const [threads] = await dbQuery(query); return threads[0]?.id.toString(); } async function serverThreadInfoFromMessageInfo( message: RawMessageInfo | MessageInfo, ): Promise { const threadID = message.threadID; const threads = await fetchServerThreadInfos({ threadID }); return threads.threadInfos[threadID]; } async function fetchContainedThreadIDs( parentThreadID: string, ): Promise> { const query = SQL` WITH RECURSIVE thread_tree AS ( SELECT id, containing_thread_id FROM threads WHERE id = ${parentThreadID} UNION ALL SELECT t.id, t.containing_thread_id FROM threads t JOIN thread_tree tt ON t.containing_thread_id = tt.id ) SELECT id FROM thread_tree `; const [result] = await dbQuery(query); return result.map(row => row.id.toString()); } export { fetchServerThreadInfos, fetchThreadInfos, rawThreadInfosFromServerThreadInfos, verifyThreadIDs, verifyThreadID, determineThreadAncestry, personalThreadQuery, fetchPersonalThreadID, serverThreadInfoFromMessageInfo, fetchContainedThreadIDs, }; diff --git a/keyserver/src/responders/thread-responders.js b/keyserver/src/responders/thread-responders.js index e31e4b4f9..04eef5b2f 100644 --- a/keyserver/src/responders/thread-responders.js +++ b/keyserver/src/responders/thread-responders.js @@ -1,272 +1,272 @@ // @flow import t from 'tcomb'; import type { TInterface, TUnion } from 'tcomb'; import { userSurfacedPermissionValidator } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type ThreadDeletionRequest, type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerNewThreadRequest, type NewThreadResponse, type ServerThreadJoinRequest, type ThreadJoinResult, type ThreadFetchMediaResult, type ThreadFetchMediaRequest, type ToggleMessagePinRequest, type ToggleMessagePinResult, type RoleModificationRequest, type RoleModificationResult, type RoleDeletionRequest, type RoleDeletionResult, } from 'lib/types/thread-types.js'; import { updateUserAvatarRequestValidator } from 'lib/utils/avatar-utils.js'; import { values } from 'lib/utils/objects.js'; import { tShape, tNumEnum, tColor, tPassword, tID, tUserID, } from 'lib/utils/validation-utils.js'; import { entryQueryInputValidator, verifyCalendarQueryThreadIDs, } from './entry-responders.js'; import { modifyRole } from '../creators/role-creator.js'; import { createThread } from '../creators/thread-creator.js'; import { deleteRole } from '../deleters/role-deleters.js'; import { deleteThread } from '../deleters/thread-deleters.js'; import { fetchMediaForThread } from '../fetchers/upload-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { updateRole, removeMembers, leaveThread, updateThread, joinThread, toggleMessagePinForThread, } from '../updaters/thread-updaters.js'; export const threadDeletionRequestInputValidator: TInterface = tShape({ threadID: tID, accountPassword: t.maybe(tPassword), }); async function threadDeletionResponder( viewer: Viewer, request: ThreadDeletionRequest, ): Promise { return await deleteThread(viewer, request); } export const roleChangeRequestInputValidator: TInterface = tShape({ threadID: tID, memberIDs: t.list(tUserID), role: t.refinement(tID, str => { if (str.indexOf('|') !== -1) { str = str.split('|')[1]; } const int = parseInt(str, 10); return String(int) === str && int > 0; }), }); async function roleUpdateResponder( viewer: Viewer, request: RoleChangeRequest, ): Promise { return await updateRole(viewer, request); } export const removeMembersRequestInputValidator: TInterface = tShape({ threadID: tID, memberIDs: t.list(tUserID), }); async function memberRemovalResponder( viewer: Viewer, request: RemoveMembersRequest, ): Promise { return await removeMembers(viewer, request); } export const leaveThreadRequestInputValidator: TInterface = tShape({ threadID: tID, }); async function threadLeaveResponder( viewer: Viewer, request: LeaveThreadRequest, ): Promise { return await leaveThread(viewer, request); } export const updateThreadRequestInputValidator: TInterface = tShape({ threadID: tID, changes: tShape({ type: t.maybe(tNumEnum(values(threadTypes))), name: t.maybe(t.String), description: t.maybe(t.String), color: t.maybe(tColor), parentThreadID: t.maybe(tID), newMemberIDs: t.maybe(t.list(tUserID)), avatar: t.maybe(updateUserAvatarRequestValidator), }), accountPassword: t.maybe(tPassword), }); async function threadUpdateResponder( viewer: Viewer, request: UpdateThreadRequest, ): Promise { return await updateThread(viewer, request); } const threadRequestValidationShape = { name: t.maybe(t.String), description: t.maybe(t.String), color: t.maybe(tColor), parentThreadID: t.maybe(tID), initialMemberIDs: t.maybe(t.list(tUserID)), calendarQuery: t.maybe(entryQueryInputValidator), }; const newThreadRequestInputValidator: TUnion = t.union([ tShape({ type: tNumEnum([threadTypes.SIDEBAR]), sourceMessageID: tID, ...threadRequestValidationShape, }), tShape({ type: tNumEnum([ threadTypes.COMMUNITY_OPEN_SUBTHREAD, threadTypes.COMMUNITY_SECRET_SUBTHREAD, - threadTypes.PERSONAL, + threadTypes.GENESIS_PERSONAL, threadTypes.LOCAL, threadTypes.COMMUNITY_ROOT, threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT, threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD, threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD, ]), ...threadRequestValidationShape, }), ]); async function threadCreationResponder( viewer: Viewer, request: ServerNewThreadRequest, ): Promise { return await createThread(viewer, request, { silentlyFailMembers: request.type === threadTypes.SIDEBAR, }); } export const joinThreadRequestInputValidator: TInterface = tShape({ threadID: tID, calendarQuery: t.maybe(entryQueryInputValidator), inviteLinkSecret: t.maybe(t.String), }); async function threadJoinResponder( viewer: Viewer, request: ServerThreadJoinRequest, ): Promise { if (request.calendarQuery) { await verifyCalendarQueryThreadIDs(request.calendarQuery); } return await joinThread(viewer, request); } export const threadFetchMediaRequestInputValidator: TInterface = tShape({ threadID: tID, limit: t.Number, offset: t.Number, }); async function threadFetchMediaResponder( viewer: Viewer, request: ThreadFetchMediaRequest, ): Promise { return await fetchMediaForThread(viewer, request); } export const toggleMessagePinRequestInputValidator: TInterface = tShape({ messageID: tID, action: t.enums.of(['pin', 'unpin']), }); async function toggleMessagePinResponder( viewer: Viewer, request: ToggleMessagePinRequest, ): Promise { return await toggleMessagePinForThread(viewer, request); } export const roleModificationRequestInputValidator: TUnion = t.union([ tShape({ community: tID, name: t.String, permissions: t.list(userSurfacedPermissionValidator), action: t.enums.of(['create_role']), }), tShape({ community: tID, existingRoleID: tID, name: t.String, permissions: t.list(userSurfacedPermissionValidator), action: t.enums.of(['edit_role']), }), ]); async function roleModificationResponder( viewer: Viewer, request: RoleModificationRequest, ): Promise { return await modifyRole(viewer, request); } export const roleDeletionRequestInputValidator: TInterface = tShape({ community: tID, roleID: tID, }); async function roleDeletionResponder( viewer: Viewer, request: RoleDeletionRequest, ): Promise { return await deleteRole(viewer, request); } export { threadDeletionResponder, roleUpdateResponder, memberRemovalResponder, threadLeaveResponder, threadUpdateResponder, threadCreationResponder, threadJoinResponder, threadFetchMediaResponder, newThreadRequestInputValidator, toggleMessagePinResponder, roleModificationResponder, roleDeletionResponder, }; diff --git a/keyserver/src/scripts/add-leave-thread-permissions.js b/keyserver/src/scripts/add-leave-thread-permissions.js index f44891b6f..8ee1b2280 100644 --- a/keyserver/src/scripts/add-leave-thread-permissions.js +++ b/keyserver/src/scripts/add-leave-thread-permissions.js @@ -1,32 +1,32 @@ // @flow import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { endScript } from './utils.js'; import { dbQuery, SQL } from '../database/database.js'; import { DEPRECATED_recalculateAllThreadPermissions } from '../updaters/thread-permission-updaters.js'; async function main() { try { await addLeaveThreadPermissions(); await DEPRECATED_recalculateAllThreadPermissions(); } catch (e) { console.warn(e); } finally { endScript(); } } async function addLeaveThreadPermissions() { const leaveThreadString = `$.${threadPermissions.LEAVE_THREAD}`; const updateAllRoles = SQL` UPDATE roles r LEFT JOIN threads t ON t.id = r.thread SET r.permissions = JSON_SET(permissions, ${leaveThreadString}, TRUE) - WHERE t.type != ${threadTypes.PERSONAL} + WHERE t.type != ${threadTypes.GENESIS_PERSONAL} `; await dbQuery(updateAllRoles); } void main(); diff --git a/keyserver/src/updaters/relationship-updaters.js b/keyserver/src/updaters/relationship-updaters.js index 7784730e6..2d48ef0ae 100644 --- a/keyserver/src/updaters/relationship-updaters.js +++ b/keyserver/src/updaters/relationship-updaters.js @@ -1,435 +1,435 @@ // @flow import invariant from 'invariant'; import { sortUserIDs } from 'lib/shared/relationship-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RelationshipErrors, type UndirectedRelationshipRow, relationshipActions, undirectedStatus, directedStatus, type RelationshipRequest, } from 'lib/types/relationship-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { NewThreadResponse } from 'lib/types/thread-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import { type UpdateData } from 'lib/types/update-types.js'; import { cartesianProduct } from 'lib/utils/array.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import createMessages from '../creators/message-creator.js'; import { createThread } from '../creators/thread-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL, mergeOrConditions } from '../database/database.js'; import { fetchFriendRequestRelationshipOperations } from '../fetchers/relationship-fetchers.js'; import { fetchUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; async function updateRelationships( viewer: Viewer, request: RelationshipRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } let requestUserIDs; const viewerID = viewer.userID; if (request.action === relationshipActions.FARCASTER_MUTUAL) { requestUserIDs = Object.keys(request.userIDsToFID).filter( userID => userID !== viewerID, ); } else { requestUserIDs = request.userIDs; } const uniqueUserIDs = [...new Set(requestUserIDs)]; const users = await fetchUserInfos(uniqueUserIDs); let errors: RelationshipErrors = {}; const userIDs: string[] = []; for (const userID of uniqueUserIDs) { if (userID === viewer.userID || !users[userID].username) { const acc = errors.invalid_user || []; errors.invalid_user = [...acc, userID]; } else { userIDs.push(userID); } } if (!userIDs.length) { return Object.freeze({ ...errors }); } const updateIDs = []; if (request.action === relationshipActions.FRIEND) { // We have to create personal threads before setting the relationship // status. By doing that we make sure that failed thread creation is // reported to the caller and can be repeated - there should be only - // one PERSONAL thread per a pair of users and we can safely call it - // repeatedly. + // one GENESIS_PERSONAL thread per a pair of users and we can safely + // call it repeatedly. const threadIDPerUser = await createPersonalThreads( viewer, request, userIDs, ); const { userRelationshipOperations, errors: friendRequestErrors } = await fetchFriendRequestRelationshipOperations(viewer, userIDs); errors = { ...errors, ...friendRequestErrors }; const undirectedInsertRows = []; const directedInsertRows = []; const directedDeleteIDs = []; const messageDatas = []; const now = Date.now(); for (const userID in userRelationshipOperations) { const operations = userRelationshipOperations[userID]; const ids = sortUserIDs(viewer.userID, userID); if (operations.length) { updateIDs.push(userID); } for (const operation of operations) { if (operation === 'delete_directed') { directedDeleteIDs.push(userID); } else if (operation === 'friend') { const [user1, user2] = ids; const status = undirectedStatus.FRIEND; undirectedInsertRows.push({ user1, user2, status }); messageDatas.push({ type: messageTypes.LEGACY_UPDATE_RELATIONSHIP, threadID: threadIDPerUser[userID], creatorID: viewer.userID, targetID: userID, time: now, operation: 'request_accepted', }); } else if (operation === 'pending_friend') { const status = directedStatus.PENDING_FRIEND; directedInsertRows.push([viewer.userID, userID, status]); messageDatas.push({ type: messageTypes.LEGACY_UPDATE_RELATIONSHIP, threadID: threadIDPerUser[userID], creatorID: viewer.userID, targetID: userID, time: now, operation: 'request_sent', }); } else if (operation === 'know_of') { const [user1, user2] = ids; const status = undirectedStatus.KNOW_OF; undirectedInsertRows.push({ user1, user2, status }); } else { invariant(false, `unexpected relationship operation ${operation}`); } } } const promises: Array> = [ updateUndirectedRelationships(undirectedInsertRows), ]; if (directedInsertRows.length) { const directedInsertQuery = SQL` INSERT INTO relationships_directed (user1, user2, status) VALUES ${directedInsertRows} ON DUPLICATE KEY UPDATE status = VALUE(status) `; promises.push(dbQuery(directedInsertQuery)); } if (directedDeleteIDs.length) { const directedDeleteQuery = SQL` DELETE FROM relationships_directed WHERE (user1 = ${viewer.userID} AND user2 IN (${directedDeleteIDs})) OR (status = ${directedStatus.PENDING_FRIEND} AND user1 IN (${directedDeleteIDs}) AND user2 = ${viewer.userID}) `; promises.push(dbQuery(directedDeleteQuery)); } if (messageDatas.length > 0) { promises.push(createMessages(viewer, messageDatas, 'broadcast')); } await Promise.all(promises); } else if (request.action === relationshipActions.UNFRIEND) { updateIDs.push(...userIDs); const updateRows = userIDs.map(userID => { const [user1, user2] = sortUserIDs(viewer.userID, userID); return { user1, user2, status: undirectedStatus.KNOW_OF }; }); const deleteQuery = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.PENDING_FRIEND} AND (user1 = ${viewer.userID} AND user2 IN (${userIDs}) OR user1 IN (${userIDs}) AND user2 = ${viewer.userID}) `; await Promise.all([ updateUndirectedRelationships(updateRows, false), dbQuery(deleteQuery), ]); } else if (request.action === relationshipActions.BLOCK) { updateIDs.push(...userIDs); const directedRows = []; const undirectedRows = []; for (const userID of userIDs) { directedRows.push([viewer.userID, userID, directedStatus.BLOCKED]); const [user1, user2] = sortUserIDs(viewer.userID, userID); undirectedRows.push({ user1, user2, status: undirectedStatus.KNOW_OF }); } const directedInsertQuery = SQL` INSERT INTO relationships_directed (user1, user2, status) VALUES ${directedRows} ON DUPLICATE KEY UPDATE status = VALUE(status) `; const directedDeleteQuery = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.PENDING_FRIEND} AND user1 IN (${userIDs}) AND user2 = ${viewer.userID} `; await Promise.all([ dbQuery(directedInsertQuery), dbQuery(directedDeleteQuery), updateUndirectedRelationships(undirectedRows, false), ]); } else if (request.action === relationshipActions.UNBLOCK) { updateIDs.push(...userIDs); const query = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.BLOCKED} AND user1 = ${viewer.userID} AND user2 IN (${userIDs}) `; await dbQuery(query); } else if (request.action === relationshipActions.FARCASTER_MUTUAL) { // We have to create personal threads before setting the relationship // status. By doing that we make sure that failed thread creation is // reported to the caller and can be repeated - there should be only - // one PERSONAL thread per a pair of users and we can safely call it - // repeatedly. + // one GENESIS_PERSONAL thread per a pair of users and we can safely + // call it repeatedly. const threadIDPerUser = await createPersonalThreads( viewer, request, userIDs, ); const viewerFID = request.userIDsToFID[viewerID]; if (!viewerFID) { throw new ServerError('viewer_fid_missing'); } const insertRows = userIDs.map(otherUserID => { const [user1, user2] = sortUserIDs(viewer.userID, otherUserID); return { user1, user2, status: undirectedStatus.FRIEND }; }); const updateDatas = await updateChangedUndirectedRelationships(insertRows); await createUpdates(updateDatas); const now = Date.now(); const messageDatas = userIDs.map(otherUserID => ({ type: messageTypes.UPDATE_RELATIONSHIP, threadID: threadIDPerUser[otherUserID], creatorID: viewer.userID, creatorFID: viewerFID, targetID: otherUserID, targetFID: request.userIDsToFID[otherUserID], time: now, operation: 'farcaster_mutual', })); await createMessages(viewer, messageDatas, 'broadcast'); } else if (request.action === relationshipActions.ACKNOWLEDGE) { updateIDs.push(...userIDs); const insertRows = userIDs.map(userID => { const [user1, user2] = sortUserIDs(viewer.userID, userID); return { user1, user2, status: undirectedStatus.KNOW_OF }; }); const updateDatas = await updateChangedUndirectedRelationships(insertRows); await createUpdates(updateDatas); } else { invariant( false, `action ${request.action} is invalid or not supported currently`, ); } await createUpdates( updateDatasForUserPairs(cartesianProduct([viewer.userID], updateIDs)), ); return Object.freeze({ ...errors }); } function updateDatasForUserPairs( userPairs: $ReadOnlyArray<[string, string]>, ): UpdateData[] { const time = Date.now(); const updateDatas: Array = []; for (const [user1, user2] of userPairs) { updateDatas.push({ type: updateTypes.UPDATE_USER, userID: user1, time, updatedUserID: user2, }); updateDatas.push({ type: updateTypes.UPDATE_USER, userID: user2, time, updatedUserID: user1, }); } return updateDatas; } async function updateUndirectedRelationships( changeset: UndirectedRelationshipRow[], greatest: boolean = true, ) { if (!changeset.length) { return; } const rows = changeset.map(row => [row.user1, row.user2, row.status]); const query = SQL` INSERT INTO relationships_undirected (user1, user2, status) VALUES ${rows} `; if (greatest) { query.append( SQL`ON DUPLICATE KEY UPDATE status = GREATEST(status, VALUE(status))`, ); } else { query.append(SQL`ON DUPLICATE KEY UPDATE status = VALUE(status)`); } await dbQuery(query); } async function updateChangedUndirectedRelationships( changeset: UndirectedRelationshipRow[], ): Promise { if (changeset.length === 0) { return []; } const user2ByUser1: Map> = new Map(); for (const { user1, user2 } of changeset) { if (!user2ByUser1.has(user1)) { user2ByUser1.set(user1, new Set()); } user2ByUser1.get(user1)?.add(user2); } const selectQuery = SQL` SELECT user1, user2, status FROM relationships_undirected WHERE `; const conditions = []; for (const [user1, users] of user2ByUser1) { conditions.push(SQL`(user1 = ${user1} AND user2 IN (${[...users]}))`); } selectQuery.append(mergeOrConditions(conditions)); const [result] = await dbQuery(selectQuery); const existingStatuses = new Map(); for (const row of result) { existingStatuses.set(`${row.user1}|${row.user2}`, row.status); } const insertRows = []; for (const row of changeset) { const existingStatus = existingStatuses.get(`${row.user1}|${row.user2}`); if (!existingStatus || existingStatus < row.status) { insertRows.push([row.user1, row.user2, row.status]); } } if (insertRows.length === 0) { return []; } const insertQuery = SQL` INSERT INTO relationships_undirected (user1, user2, status) VALUES ${insertRows} ON DUPLICATE KEY UPDATE status = GREATEST(status, VALUE(status)) `; await dbQuery(insertQuery); return updateDatasForUserPairs( insertRows.map(([user1, user2]) => [user1, user2]), ); } async function createPersonalThreads( viewer: Viewer, request: RelationshipRequest, userIDs: $ReadOnlyArray, ) { invariant( request.action === relationshipActions.FRIEND || request.action === relationshipActions.FARCASTER_MUTUAL, - 'We should only create a PERSONAL threads when sending FRIEND or ' + + 'We should only create a GENESIS_PERSONAL threads when sending FRIEND or ' + 'FARCASTER_MUTUAL requests, but we tried to do that for ' + request.action, ); const threadIDPerUser: { [string]: string } = {}; const personalThreadsQuery = SQL` SELECT t.id AS threadID, m2.user AS user2 FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id AND m1.user = ${viewer.userID} INNER JOIN memberships m2 ON m2.thread = t.id AND m2.user IN (${userIDs}) - WHERE t.type = ${threadTypes.PERSONAL} + WHERE t.type = ${threadTypes.GENESIS_PERSONAL} AND m1.role > 0 AND m2.role > 0 `; const [personalThreadsResult] = await dbQuery(personalThreadsQuery); for (const row of personalThreadsResult) { const user2 = row.user2.toString(); threadIDPerUser[user2] = row.threadID.toString(); } const threadCreationPromises: { [string]: Promise } = {}; for (const userID of userIDs) { if (threadIDPerUser[userID]) { continue; } threadCreationPromises[userID] = createThread( viewer, { - type: threadTypes.PERSONAL, + type: threadTypes.GENESIS_PERSONAL, initialMemberIDs: [userID], }, { forceAddMembers: true, updatesForCurrentSession: 'broadcast' }, ); } const personalThreadPerUser = await promiseAll(threadCreationPromises); for (const userID in personalThreadPerUser) { const newThread = personalThreadPerUser[userID]; threadIDPerUser[userID] = newThread.newThreadID; } return threadIDPerUser; } export { updateRelationships, updateDatasForUserPairs, updateUndirectedRelationships, updateChangedUndirectedRelationships, }; diff --git a/lib/components/chat-mention-provider.react.js b/lib/components/chat-mention-provider.react.js index 21f666a29..8c1a9e7f6 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) { 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 + lastThreadInTraversePath.type !== threadTypes.GENESIS_PERSONAL && + lastThreadInTraversePath.type !== threadTypes.GENESIS_PRIVATE ) { 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/permissions/minimally-encoded-thread-permissions-test-data.js b/lib/permissions/minimally-encoded-thread-permissions-test-data.js index 24e53024b..2ee60f702 100644 --- a/lib/permissions/minimally-encoded-thread-permissions-test-data.js +++ b/lib/permissions/minimally-encoded-thread-permissions-test-data.js @@ -1,726 +1,726 @@ // @flow import type { RawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { LegacyRawThreadInfo } from '../types/thread-types.js'; const exampleRawThreadInfoA: LegacyRawThreadInfo = { id: '85171', - type: threadTypes.PERSONAL, + type: threadTypes.GENESIS_PERSONAL, name: '', description: '', color: '6d49ab', creationTime: 1675887298557, parentThreadID: '1', members: [ { id: '256', role: null, permissions: { know_of: { value: true, source: '1', }, visible: { value: true, source: '1', }, voiced: { value: true, source: '1', }, edit_entries: { value: true, source: '1', }, edit_thread: { value: true, source: '1', }, edit_thread_description: { value: true, source: '1', }, edit_thread_color: { value: true, source: '1', }, delete_thread: { value: true, source: '1', }, create_subthreads: { value: true, source: '1', }, create_sidebars: { value: true, source: '1', }, join_thread: { value: true, source: '1', }, edit_permissions: { value: true, source: '1', }, add_members: { value: true, source: '1', }, remove_members: { value: true, source: '1', }, change_role: { value: true, source: '1', }, leave_thread: { value: false, source: null, }, react_to_message: { value: false, source: null, }, edit_message: { value: false, source: null, }, manage_pins: { value: true, source: '1', }, voiced_in_announcement_channels: { value: false, source: null, }, manage_farcaster_channel_tags: { value: false, source: null, }, }, isSender: false, }, { id: '83853', role: '85172', permissions: { know_of: { value: true, source: '85171', }, visible: { value: true, source: '85171', }, voiced: { value: true, source: '85171', }, edit_entries: { value: true, source: '85171', }, edit_thread: { value: true, source: '85171', }, edit_thread_description: { value: true, source: '85171', }, edit_thread_color: { value: true, source: '85171', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: '85171', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: '85171', }, edit_message: { value: true, source: '85171', }, manage_pins: { value: false, source: null, }, voiced_in_announcement_channels: { value: false, source: null, }, manage_farcaster_channel_tags: { value: false, source: null, }, }, isSender: true, }, ], roles: { '85172': { id: '85172', name: 'Members', permissions: { know_of: true, visible: true, voiced: true, react_to_message: true, edit_message: true, edit_entries: true, edit_thread: true, edit_thread_color: true, edit_thread_description: true, create_sidebars: true, descendant_open_know_of: true, descendant_open_visible: true, child_open_join_thread: true, }, isDefault: true, }, }, currentUser: { role: '85172', permissions: { know_of: { value: true, source: '85171', }, visible: { value: true, source: '85171', }, voiced: { value: true, source: '85171', }, edit_entries: { value: true, source: '85171', }, edit_thread: { value: true, source: '85171', }, edit_thread_description: { value: true, source: '85171', }, edit_thread_color: { value: true, source: '85171', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: '85171', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: '85171', }, edit_message: { value: true, source: '85171', }, manage_pins: { value: false, source: null, }, voiced_in_announcement_channels: { value: false, source: null, }, manage_farcaster_channel_tags: { value: false, source: null, }, }, subscription: { home: true, pushNotifs: true, }, unread: false, }, repliesCount: 0, containingThreadID: '1', community: '1', pinnedCount: 0, }; const exampleMinimallyEncodedRawThreadInfoA: RawThreadInfo = { minimallyEncoded: true, id: '85171', - type: threadTypes.PERSONAL, + type: threadTypes.GENESIS_PERSONAL, name: '', description: '', color: '6d49ab', creationTime: 1675887298557, parentThreadID: '1', members: [ { minimallyEncoded: true, id: '256', role: null, permissions: '87fff', isSender: false, }, { minimallyEncoded: true, id: '83853', role: '85172', permissions: '3027f', isSender: true, }, ], roles: { '85172': { minimallyEncoded: true, id: '85172', name: 'Members', permissions: [ '000', '010', '020', '100', '110', '030', '040', '060', '050', '090', '005', '015', '0a9', ], }, }, currentUser: { minimallyEncoded: true, role: '85172', permissions: '3027f', subscription: { home: true, pushNotifs: true, }, unread: false, }, repliesCount: 0, containingThreadID: '1', community: '1', pinnedCount: 0, }; const expectedDecodedExampleRawThreadInfoA: LegacyRawThreadInfo = { id: '85171', - type: threadTypes.PERSONAL, + type: threadTypes.GENESIS_PERSONAL, name: '', description: '', color: '6d49ab', creationTime: 1675887298557, parentThreadID: '1', members: [ { id: '256', role: null, permissions: { know_of: { value: true, source: 'null', }, visible: { value: true, source: 'null', }, voiced: { value: true, source: 'null', }, edit_entries: { value: true, source: 'null', }, edit_thread: { value: true, source: 'null', }, edit_thread_description: { value: true, source: 'null', }, edit_thread_color: { value: true, source: 'null', }, delete_thread: { value: true, source: 'null', }, create_subthreads: { value: true, source: 'null', }, create_sidebars: { value: true, source: 'null', }, join_thread: { value: true, source: 'null', }, edit_permissions: { value: true, source: 'null', }, add_members: { value: true, source: 'null', }, remove_members: { value: true, source: 'null', }, change_role: { value: true, source: 'null', }, leave_thread: { value: false, source: null, }, react_to_message: { value: false, source: null, }, edit_message: { value: false, source: null, }, manage_pins: { value: true, source: 'null', }, manage_invite_links: { source: null, value: false, }, edit_thread_avatar: { source: null, value: false, }, voiced_in_announcement_channels: { value: false, source: null, }, manage_farcaster_channel_tags: { value: false, source: null, }, }, isSender: false, }, { id: '83853', role: '85172', permissions: { know_of: { value: true, source: 'null', }, visible: { value: true, source: 'null', }, voiced: { value: true, source: 'null', }, edit_entries: { value: true, source: 'null', }, edit_thread: { value: true, source: 'null', }, edit_thread_description: { value: true, source: 'null', }, edit_thread_color: { value: true, source: 'null', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: 'null', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: 'null', }, edit_message: { value: true, source: 'null', }, manage_pins: { value: false, source: null, }, manage_invite_links: { source: null, value: false, }, edit_thread_avatar: { source: null, value: false, }, voiced_in_announcement_channels: { value: false, source: null, }, manage_farcaster_channel_tags: { value: false, source: null, }, }, isSender: true, }, ], roles: { '85172': { id: '85172', name: 'Members', permissions: { know_of: true, visible: true, voiced: true, react_to_message: true, edit_message: true, edit_entries: true, edit_thread: true, edit_thread_color: true, edit_thread_description: true, create_sidebars: true, descendant_open_know_of: true, descendant_open_visible: true, child_open_join_thread: true, }, isDefault: true, }, }, currentUser: { role: '85172', permissions: { know_of: { value: true, source: 'null', }, visible: { value: true, source: 'null', }, voiced: { value: true, source: 'null', }, edit_entries: { value: true, source: 'null', }, edit_thread: { value: true, source: 'null', }, edit_thread_description: { value: true, source: 'null', }, edit_thread_color: { value: true, source: 'null', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: 'null', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: 'null', }, edit_message: { value: true, source: 'null', }, manage_pins: { value: false, source: null, }, manage_invite_links: { source: null, value: false, }, edit_thread_avatar: { source: null, value: false, }, voiced_in_announcement_channels: { value: false, source: null, }, manage_farcaster_channel_tags: { value: false, source: null, }, }, subscription: { home: true, pushNotifs: true, }, unread: false, }, repliesCount: 0, containingThreadID: '1', community: '1', pinnedCount: 0, }; export { exampleRawThreadInfoA, exampleMinimallyEncodedRawThreadInfoA, expectedDecodedExampleRawThreadInfoA, }; diff --git a/lib/permissions/special-roles.test.js b/lib/permissions/special-roles.test.js index e2b6106a3..622d7d324 100644 --- a/lib/permissions/special-roles.test.js +++ b/lib/permissions/special-roles.test.js @@ -1,223 +1,223 @@ // @flow import { patchRawThreadInfosWithSpecialRole, patchRoleInfoWithSpecialRole, specialRoles, } from './special-roles.js'; import type { RoleInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { RawThreadInfos } from '../types/thread-types.js'; describe('patchRoleInfoWithSpecialRole', () => { it('should correctly set DEFAULT_ROLE', () => { const role: RoleInfo = { minimallyEncoded: true, id: 'roleID', name: 'roleName', permissions: ['abc', 'def'], specialRole: specialRoles.DEFAULT_ROLE, }; const patchedRole = patchRoleInfoWithSpecialRole(role); expect(patchedRole.specialRole).toBe(specialRoles.DEFAULT_ROLE); }); it('should correctly set ADMIN_ROLE', () => { const role: RoleInfo = { minimallyEncoded: true, id: 'roleID', name: 'Admins', permissions: ['abc', 'def'], }; const patchedRole = patchRoleInfoWithSpecialRole(role); expect(patchedRole.specialRole).toBe(specialRoles.ADMIN_ROLE); }); it('should correctly set undefined', () => { const role: RoleInfo = { minimallyEncoded: true, id: 'roleID', name: 'BLAH', permissions: ['abc', 'def'], }; const patchedRole = patchRoleInfoWithSpecialRole(role); expect(patchedRole.specialRole).toBe(null); }); }); const rawThreadInfos: RawThreadInfos = { '256|1': { minimallyEncoded: true, id: '256|1', type: threadTypes.GENESIS, 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.', color: 'c85000', creationTime: 1702415956354, parentThreadID: null, containingThreadID: null, community: null, members: [ { id: '256', role: '256|83796', permissions: '3f73ff', isSender: false, minimallyEncoded: true, }, { id: '83809', role: '256|83795', permissions: '3', isSender: false, minimallyEncoded: true, }, ], roles: { '256|83795': { id: '256|83795', name: 'Members', permissions: ['000', '010', '005', '015', '0a7'], minimallyEncoded: true, specialRole: specialRoles.DEFAULT_ROLE, }, '256|83796': { id: '256|83796', name: 'Admins', permissions: [ '000', '010', '020', '100', '110', '030', '040', '060', '050', '120', '080', '090', '0c0', '070', '0d0', '0e0', '130', '140', '150', '004', '014', '0a6', '0a8', '024', '034', '044', '064', '054', '124', '086', '096', '0c4', '074', '0b4', '0d4', '0e4', '134', '156', ], minimallyEncoded: true, }, }, currentUser: { role: '256|83795', permissions: '3', subscription: { home: true, pushNotifs: true, }, unread: false, minimallyEncoded: true, }, repliesCount: 0, pinnedCount: 0, }, '256|83814': { id: '256|83814', - type: threadTypes.PRIVATE, + type: threadTypes.GENESIS_PRIVATE, name: '', description: 'This is your private chat, where you can set reminders and jot notes in private!', color: 'aa4b4b', creationTime: 1702415964471, parentThreadID: '256|1', repliesCount: 0, containingThreadID: '256|1', community: '256|1', pinnedCount: 0, minimallyEncoded: true, members: [ { id: '256', role: null, permissions: '2c7fff', isSender: false, minimallyEncoded: true, }, { id: '83809', role: '256|83815', permissions: '3026f', isSender: true, minimallyEncoded: true, }, ], roles: { '256|83815': { id: '256|83815', name: 'NotMembers', permissions: [ '000', '010', '020', '100', '110', '060', '050', '090', '030', '005', '015', '0a9', ], minimallyEncoded: true, }, }, currentUser: { role: '256|83815', permissions: '3026f', subscription: { home: true, pushNotifs: true, }, unread: false, minimallyEncoded: true, }, }, }; describe('patchRawThreadInfosWithSpecialRole', () => { it('should correctly set special roles', () => { const patchedRawThreadInfos = patchRawThreadInfosWithSpecialRole(rawThreadInfos); expect(patchedRawThreadInfos['256|1'].roles['256|83795'].specialRole).toBe( specialRoles.DEFAULT_ROLE, ); expect(patchedRawThreadInfos['256|1'].roles['256|83796'].specialRole).toBe( specialRoles.ADMIN_ROLE, ); expect( patchedRawThreadInfos['256|83814'].roles['256|83815'].specialRole, ).toBe(null); }); }); diff --git a/lib/permissions/thread-permissions.js b/lib/permissions/thread-permissions.js index 35cd8f615..c895a7177 100644 --- a/lib/permissions/thread-permissions.js +++ b/lib/permissions/thread-permissions.js @@ -1,508 +1,508 @@ // @flow import invariant from 'invariant'; import { parseThreadPermissionString, includeThreadPermissionForThreadType, } from './prefixes.js'; import { configurableCommunityPermissions, threadPermissionFilterPrefixes, threadPermissionPropagationPrefixes, threadPermissions, userSurfacedPermissions, } from '../types/thread-permission-types.js'; import type { ThreadPermission, ThreadPermissionInfo, ThreadPermissionsBlob, ThreadPermissionsInfo, ThreadRolePermissionsBlob, UserSurfacedPermission, } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes, threadTypeIsAnnouncementThread, } from '../types/thread-types-enum.js'; function permissionLookup( permissions: ?ThreadPermissionsBlob | ?ThreadPermissionsInfo, permission: ThreadPermission, ): boolean { return !!( permissions && permissions[permission] && permissions[permission].value && permissions[threadPermissions.KNOW_OF] && permissions[threadPermissions.KNOW_OF].value ); } function getAllThreadPermissions( permissions: ?ThreadPermissionsBlob, threadID: string, ): ThreadPermissionsInfo { const result: { [permission: ThreadPermission]: ThreadPermissionInfo } = {}; for (const permissionName in threadPermissions) { const permissionKey = threadPermissions[permissionName]; const permission = permissionLookup(permissions, permissionKey); let entry: ThreadPermissionInfo = { value: false, source: null }; if (permission) { const blobEntry = permissions ? permissions[permissionKey] : null; if (blobEntry) { invariant( blobEntry.value, 'permissionLookup returned true but blob had false permission!', ); entry = { value: true, source: blobEntry.source }; } else { entry = { value: true, source: threadID }; } } result[permissionKey] = entry; } return result; } // - rolePermissions can be null if role <= 0, ie. not a member // - permissionsFromParent can be null if there are no permissions from the // parent // - return can be null if no permissions exist function makePermissionsBlob( rolePermissions: ?ThreadRolePermissionsBlob, permissionsFromParent: ?ThreadPermissionsBlob, threadID: string, threadType: ThreadType, ): ?ThreadPermissionsBlob { let permissions: { [permission: string]: ThreadPermissionInfo } = {}; if (permissionsFromParent) { for (const permissionKey in permissionsFromParent) { const permissionValue = permissionsFromParent[permissionKey]; const parsed = parseThreadPermissionString(permissionKey); if (!includeThreadPermissionForThreadType(parsed, threadType)) { continue; } if (parsed.propagationPrefix) { permissions[permissionKey] = permissionValue; } else { permissions[parsed.permission] = permissionValue; } } } const combinedPermissions: { [permission: string]: ThreadPermissionInfo, } = { ...permissions }; if (rolePermissions) { for (const permissionKey in rolePermissions) { const permissionValue = rolePermissions[permissionKey]; const currentValue = combinedPermissions[permissionKey]; if (permissionValue) { combinedPermissions[permissionKey] = { value: true, source: threadID, }; } else if (!currentValue || !currentValue.value) { combinedPermissions[permissionKey] = { value: false, source: null, }; } } } if (permissionLookup(combinedPermissions, threadPermissions.KNOW_OF)) { permissions = combinedPermissions; } const threadIsAnnouncementThread = threadTypeIsAnnouncementThread(threadType); const hasVoicedInAnnouncementChannelsPermission = permissionLookup( (permissions: ThreadPermissionsBlob), threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, ); const isMember = !!rolePermissions; if ( threadIsAnnouncementThread && hasVoicedInAnnouncementChannelsPermission && isMember ) { permissions[threadPermissions.VOICED] = { value: true, source: threadID, }; } if (Object.keys(permissions).length === 0) { return null; } return permissions; } function makePermissionsForChildrenBlob( permissions: ?ThreadPermissionsBlob, ): ?ThreadPermissionsBlob { if (!permissions) { return null; } const permissionsForChildren: { [permission: string]: ThreadPermissionInfo } = {}; for (const permissionKey in permissions) { const permissionValue = permissions[permissionKey]; const parsed = parseThreadPermissionString(permissionKey); if (!parsed.propagationPrefix) { continue; } if ( parsed.propagationPrefix === threadPermissionPropagationPrefixes.DESCENDANT ) { permissionsForChildren[permissionKey] = permissionValue; } const permissionWithFilterPrefix = parsed.filterPrefix ? `${parsed.filterPrefix}${parsed.permission}` : parsed.permission; permissionsForChildren[permissionWithFilterPrefix] = permissionValue; } if (Object.keys(permissionsForChildren).length === 0) { return null; } return permissionsForChildren; } function getRoleForPermissions( inputRole: string, permissions: ?ThreadPermissionsBlob, ): string { if (!permissionLookup(permissions, threadPermissions.KNOW_OF)) { return '-1'; } else if (Number(inputRole) <= 0) { return '0'; } else { return inputRole; } } function getThreadPermissionBlobFromUserSurfacedPermissions( communityUserSurfacedPermissions: $ReadOnlyArray, threadType: ThreadType, ): ThreadRolePermissionsBlob { const mappedUserSurfacedPermissions = communityUserSurfacedPermissions .map(permission => [...configurableCommunityPermissions[permission]]) .flat(); const userSurfacedPermissionsObj = Object.fromEntries( mappedUserSurfacedPermissions.map(p => [p, true]), ); const universalCommunityPermissions = getUniversalCommunityRootPermissionsBlob(threadType); return { ...universalCommunityPermissions, ...userSurfacedPermissionsObj, }; } export type RolePermissionBlobs = { +Members: ThreadRolePermissionsBlob, +Admins?: ThreadRolePermissionsBlob, }; const { CHILD, DESCENDANT } = threadPermissionPropagationPrefixes; const { OPEN, TOP_LEVEL, OPEN_TOP_LEVEL } = threadPermissionFilterPrefixes; const OPEN_CHILD = CHILD + OPEN; const OPEN_DESCENDANT = DESCENDANT + OPEN; const TOP_LEVEL_DESCENDANT = DESCENDANT + TOP_LEVEL; const OPEN_TOP_LEVEL_DESCENDANT = DESCENDANT + OPEN_TOP_LEVEL; const baseMemberUserSurfacedPermissions = [ userSurfacedPermissions.REACT_TO_MESSAGES, userSurfacedPermissions.EDIT_MESSAGES, userSurfacedPermissions.ADD_MEMBERS, ]; const baseVoicedUserSurfacedPermissions = [ userSurfacedPermissions.EDIT_CALENDAR, userSurfacedPermissions.CREATE_AND_EDIT_CHANNELS, ]; function getRolePermissionBlobsForCommunityRoot( threadType: ThreadType, ): RolePermissionBlobs { let memberUserSurfacedPermissions; if (threadType === threadTypes.GENESIS) { memberUserSurfacedPermissions = []; } else if (threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT) { memberUserSurfacedPermissions = baseMemberUserSurfacedPermissions; } else { memberUserSurfacedPermissions = [ ...baseMemberUserSurfacedPermissions, ...baseVoicedUserSurfacedPermissions, ]; } const memberPermissions = getThreadPermissionBlobFromUserSurfacedPermissions( memberUserSurfacedPermissions, threadType, ); const descendantKnowOf = DESCENDANT + threadPermissions.KNOW_OF; const descendantVisible = DESCENDANT + threadPermissions.VISIBLE; const topLevelDescendantJoinThread = TOP_LEVEL_DESCENDANT + threadPermissions.JOIN_THREAD; const childJoinThread = CHILD + threadPermissions.JOIN_THREAD; const descendantVoiced = DESCENDANT + threadPermissions.VOICED; const descendantEditEntries = DESCENDANT + threadPermissions.EDIT_ENTRIES; const descendantEditThreadName = DESCENDANT + threadPermissions.EDIT_THREAD_NAME; const descendantEditThreadColor = DESCENDANT + threadPermissions.EDIT_THREAD_COLOR; const descendantEditThreadDescription = DESCENDANT + threadPermissions.EDIT_THREAD_DESCRIPTION; const descendantEditThreadAvatar = DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR; const topLevelDescendantCreateSubchannels = TOP_LEVEL_DESCENDANT + threadPermissions.CREATE_SUBCHANNELS; const topLevelDescendantCreateSidebars = TOP_LEVEL_DESCENDANT + threadPermissions.CREATE_SIDEBARS; const descendantAddMembers = DESCENDANT + threadPermissions.ADD_MEMBERS; const descendantDeleteThread = DESCENDANT + threadPermissions.DELETE_THREAD; const descendantEditPermissions = DESCENDANT + threadPermissions.EDIT_PERMISSIONS; const descendantRemoveMembers = DESCENDANT + threadPermissions.REMOVE_MEMBERS; const descendantChangeRole = DESCENDANT + threadPermissions.CHANGE_ROLE; const descendantManagePins = DESCENDANT + threadPermissions.MANAGE_PINS; const topLevelDescendantVoicedInAnnouncementChannels = TOP_LEVEL_DESCENDANT + threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS; const baseAdminPermissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, [threadPermissions.REACT_TO_MESSAGE]: true, [threadPermissions.EDIT_MESSAGE]: true, [threadPermissions.EDIT_ENTRIES]: true, [threadPermissions.EDIT_THREAD_NAME]: true, [threadPermissions.EDIT_THREAD_COLOR]: true, [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, [threadPermissions.EDIT_THREAD_AVATAR]: true, [threadPermissions.CREATE_SUBCHANNELS]: true, [threadPermissions.CREATE_SIDEBARS]: true, [threadPermissions.DELETE_THREAD]: true, [threadPermissions.REMOVE_MEMBERS]: true, [threadPermissions.CHANGE_ROLE]: true, [threadPermissions.MANAGE_PINS]: true, [threadPermissions.MANAGE_INVITE_LINKS]: true, [threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS]: true, [threadPermissions.MANAGE_FARCASTER_CHANNEL_TAGS]: true, [descendantKnowOf]: true, [descendantVisible]: true, [topLevelDescendantJoinThread]: true, [childJoinThread]: true, [descendantVoiced]: true, [descendantEditEntries]: true, [descendantEditThreadName]: true, [descendantEditThreadColor]: true, [descendantEditThreadDescription]: true, [descendantEditThreadAvatar]: true, [topLevelDescendantCreateSubchannels]: true, [topLevelDescendantCreateSidebars]: true, [descendantAddMembers]: true, [descendantDeleteThread]: true, [descendantEditPermissions]: true, [descendantRemoveMembers]: true, [descendantChangeRole]: true, [descendantManagePins]: true, [topLevelDescendantVoicedInAnnouncementChannels]: true, }; let adminPermissions; if (threadType === threadTypes.GENESIS) { adminPermissions = { ...baseAdminPermissions, [threadPermissions.ADD_MEMBERS]: true, }; } else { adminPermissions = { ...baseAdminPermissions, [threadPermissions.LEAVE_THREAD]: true, }; } return { Members: memberPermissions, Admins: adminPermissions, }; } const nonCommunityVoicedPermissions = { [threadPermissions.VOICED]: true, [threadPermissions.EDIT_ENTRIES]: true, [threadPermissions.EDIT_THREAD_NAME]: true, [threadPermissions.EDIT_THREAD_COLOR]: true, [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, [threadPermissions.EDIT_THREAD_AVATAR]: true, [threadPermissions.CREATE_SUBCHANNELS]: true, [threadPermissions.ADD_MEMBERS]: true, }; function getRolePermissionBlobs(threadType: ThreadType): RolePermissionBlobs { if (threadType === threadTypes.SIDEBAR) { const memberPermissions = { [threadPermissions.VOICED]: true, [threadPermissions.REACT_TO_MESSAGE]: true, [threadPermissions.EDIT_MESSAGE]: true, [threadPermissions.EDIT_THREAD_NAME]: true, [threadPermissions.EDIT_THREAD_COLOR]: true, [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, [threadPermissions.EDIT_THREAD_AVATAR]: true, [threadPermissions.ADD_MEMBERS]: true, [threadPermissions.EDIT_PERMISSIONS]: true, [threadPermissions.REMOVE_MEMBERS]: true, [threadPermissions.LEAVE_THREAD]: true, }; return { Members: memberPermissions, }; } const openDescendantKnowOf = OPEN_DESCENDANT + threadPermissions.KNOW_OF; const openDescendantVisible = OPEN_DESCENDANT + threadPermissions.VISIBLE; const openChildJoinThread = OPEN_CHILD + threadPermissions.JOIN_THREAD; - if (threadType === threadTypes.PRIVATE) { + if (threadType === threadTypes.GENESIS_PRIVATE) { const memberPermissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, [threadPermissions.REACT_TO_MESSAGE]: true, [threadPermissions.EDIT_MESSAGE]: true, [threadPermissions.EDIT_THREAD_COLOR]: true, [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, [threadPermissions.CREATE_SIDEBARS]: true, [threadPermissions.EDIT_ENTRIES]: true, [openDescendantKnowOf]: true, [openDescendantVisible]: true, [openChildJoinThread]: true, }; return { Members: memberPermissions, }; } - if (threadType === threadTypes.PERSONAL) { + if (threadType === threadTypes.GENESIS_PERSONAL) { return { Members: { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, [threadPermissions.REACT_TO_MESSAGE]: true, [threadPermissions.EDIT_MESSAGE]: true, [threadPermissions.EDIT_ENTRIES]: true, [threadPermissions.EDIT_THREAD_NAME]: true, [threadPermissions.EDIT_THREAD_COLOR]: true, [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, [threadPermissions.CREATE_SIDEBARS]: true, [openDescendantKnowOf]: true, [openDescendantVisible]: true, [openChildJoinThread]: true, }, }; } const openTopLevelDescendantJoinThread = OPEN_TOP_LEVEL_DESCENDANT + threadPermissions.JOIN_THREAD; const subthreadBasePermissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.REACT_TO_MESSAGE]: true, [threadPermissions.EDIT_MESSAGE]: true, [threadPermissions.CREATE_SIDEBARS]: true, [threadPermissions.LEAVE_THREAD]: true, [openDescendantKnowOf]: true, [openDescendantVisible]: true, [openTopLevelDescendantJoinThread]: true, [openChildJoinThread]: true, }; if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD ) { const memberPermissions = { [threadPermissions.REMOVE_MEMBERS]: true, [threadPermissions.EDIT_PERMISSIONS]: true, ...subthreadBasePermissions, ...nonCommunityVoicedPermissions, }; return { Members: memberPermissions, }; } if ( threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD ) { return { Members: subthreadBasePermissions, }; } return getRolePermissionBlobsForCommunityRoot(threadType); } // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return function getUniversalCommunityRootPermissionsBlob( threadType: ThreadType, ): ThreadRolePermissionsBlob { const openDescendantKnowOf = OPEN_DESCENDANT + threadPermissions.KNOW_OF; const openDescendantVisible = OPEN_DESCENDANT + threadPermissions.VISIBLE; const openChildJoinThread = OPEN_CHILD + threadPermissions.JOIN_THREAD; const openTopLevelDescendantJoinThread = OPEN_TOP_LEVEL_DESCENDANT + threadPermissions.JOIN_THREAD; const genesisUniversalCommunityPermissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [openDescendantKnowOf]: true, [openDescendantVisible]: true, [openTopLevelDescendantJoinThread]: true, }; const baseUniversalCommunityPermissions = { ...genesisUniversalCommunityPermissions, [threadPermissions.CREATE_SIDEBARS]: true, [threadPermissions.LEAVE_THREAD]: true, [openChildJoinThread]: true, }; if (threadType === threadTypes.GENESIS) { return genesisUniversalCommunityPermissions; } else if (threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT) { return baseUniversalCommunityPermissions; } else if (threadType === threadTypes.COMMUNITY_ROOT) { return { ...baseUniversalCommunityPermissions, [threadPermissions.VOICED]: true, }; } invariant(false, 'invalid threadType parameter'); } export { permissionLookup, getAllThreadPermissions, makePermissionsBlob, makePermissionsForChildrenBlob, getRoleForPermissions, getThreadPermissionBlobFromUserSurfacedPermissions, getRolePermissionBlobs, getUniversalCommunityRootPermissionsBlob, }; diff --git a/lib/selectors/user-selectors.js b/lib/selectors/user-selectors.js index 8fff34e58..e58ac0379 100644 --- a/lib/selectors/user-selectors.js +++ b/lib/selectors/user-selectors.js @@ -1,270 +1,270 @@ // @flow import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import bots from '../facts/bots.js'; import { getAvatarForUser, getRandomDefaultEmojiAvatar, } from '../shared/avatar-utils.js'; import { getSingleOtherUser } from '../shared/thread-utils.js'; import { type AuxUserInfos, type AuxUserInfo, } from '../types/aux-user-types.js'; import type { ClientEmojiAvatar } from '../types/avatar-types'; import type { RelativeMemberInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { RawThreadInfos } from '../types/thread-types.js'; import type { UserInfos, RelativeUserInfo, AccountUserInfo, CurrentUserInfo, } from '../types/user-types.js'; import { entries, values } from '../utils/objects.js'; // Used for specific message payloads that include an array of user IDs, ie. // array of initial users, array of added users function userIDsToRelativeUserInfos( userIDs: $ReadOnlyArray, viewerID: ?string, userInfos: UserInfos, ): RelativeUserInfo[] { const relativeUserInfos: RelativeUserInfo[] = []; for (const userID of userIDs) { const username = userInfos[userID] ? userInfos[userID].username : null; const relativeUserInfo = { id: userID, username, isViewer: userID === viewerID, }; if (userID === viewerID) { relativeUserInfos.unshift(relativeUserInfo); } else { relativeUserInfos.push(relativeUserInfo); } } return relativeUserInfos; } function getRelativeMemberInfos( threadInfo: ?RawThreadInfo, currentUserID: ?string, userInfos: UserInfos, ): $ReadOnlyArray { const relativeMemberInfos: RelativeMemberInfo[] = []; if (!threadInfo) { return relativeMemberInfos; } const memberInfos = threadInfo.members; for (const memberInfo of memberInfos) { if (!memberInfo.role) { continue; } const username = userInfos[memberInfo.id] ? userInfos[memberInfo.id].username : null; const { permissions, ...memberInfoSansPermissions } = memberInfo; if (memberInfo.id === currentUserID) { relativeMemberInfos.unshift({ ...memberInfoSansPermissions, username, isViewer: true, }); } else { relativeMemberInfos.push({ ...memberInfoSansPermissions, username, isViewer: false, }); } } return relativeMemberInfos; } const emptyArray: $ReadOnlyArray = []; // Includes current user at the start const baseRelativeMemberInfoSelectorForMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<>) => $ReadOnlyArray = ( threadID: ?string, ) => { if (!threadID) { return () => emptyArray; } return createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos[threadID], (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.userStore.userInfos, getRelativeMemberInfos, ); }; const relativeMemberInfoSelectorForMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseRelativeMemberInfoSelectorForMembersOfThread, ); const userInfoSelectorForPotentialMembers: (state: BaseAppState<>) => { [id: string]: AccountUserInfo, } = createSelector( (state: BaseAppState<>) => state.userStore.userInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, ( userInfos: UserInfos, currentUserID: ?string, ): { [id: string]: AccountUserInfo } => { const availableUsers: { [id: string]: AccountUserInfo } = {}; for (const id in userInfos) { const { username, relationshipStatus } = userInfos[id]; if (id === currentUserID || !username) { continue; } if ( relationshipStatus !== userRelationshipStatus.BLOCKED_VIEWER && relationshipStatus !== userRelationshipStatus.BOTH_BLOCKED ) { availableUsers[id] = { id, username, relationshipStatus }; } } return availableUsers; }, ); const isLoggedIn = (state: BaseAppState<>): boolean => !!( state.currentUserInfo && !state.currentUserInfo.anonymous && state.dataLoaded ); const isLoggedInToKeyserver: ( keyserverID: ?string, ) => (state: BaseAppState<>) => boolean = _memoize( (keyserverID: ?string) => (state: BaseAppState<>) => { if (!keyserverID) { return false; } const cookie = state.keyserverStore.keyserverInfos[keyserverID]?.cookie; return !!cookie && cookie.startsWith('user='); }, ); const usersWithPersonalThreadSelector: ( state: BaseAppState<>, ) => $ReadOnlySet = createSelector( (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.threadStore.threadInfos, (viewerID: ?string, threadInfos: RawThreadInfos) => { const personalThreadMembers = new Set(); for (const threadID in threadInfos) { const thread = threadInfos[threadID]; if ( - thread.type !== threadTypes.PERSONAL || + thread.type !== threadTypes.GENESIS_PERSONAL || !thread.members.find(member => member.id === viewerID) ) { continue; } const otherMemberID = getSingleOtherUser(thread, viewerID); if (otherMemberID) { personalThreadMembers.add(otherMemberID); } } return personalThreadMembers; }, ); const savedEmojiAvatarSelectorForCurrentUser: ( state: BaseAppState<>, ) => () => ClientEmojiAvatar = createSelector( (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo, (currentUser: ?CurrentUserInfo) => { return () => { let userAvatar = getAvatarForUser(currentUser); if (userAvatar.type !== 'emoji') { userAvatar = getRandomDefaultEmojiAvatar(); } return userAvatar; }; }, ); const getRelativeUserIDs: (state: BaseAppState<>) => $ReadOnlyArray = createSelector( (state: BaseAppState<>) => state.userStore.userInfos, (userInfos: UserInfos): $ReadOnlyArray => Object.keys(userInfos), ); const usersWithMissingDeviceListSelector: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( getRelativeUserIDs, (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, ( userIDs: $ReadOnlyArray, auxUserInfos: AuxUserInfos, ): $ReadOnlyArray => userIDs.filter( userID => (!auxUserInfos[userID] || !auxUserInfos[userID].deviceList) && userID !== bots.commbot.userID, ), ); // Foreign Peer Devices are all devices of users we are aware of, // but not our own devices. const getForeignPeerDevices: (state: BaseAppState<>) => $ReadOnlyArray = createSelector( (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, ( auxUserInfos: AuxUserInfos, currentUserID: ?string, ): $ReadOnlyArray => entries(auxUserInfos) .map(([userID, auxUserInfo]: [string, AuxUserInfo]) => userID !== currentUserID && auxUserInfo.deviceList?.devices ? auxUserInfo.deviceList.devices : [], ) .flat(), ); const getAllPeerDevices: (state: BaseAppState<>) => $ReadOnlyArray = createSelector( (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, (auxUserInfos: AuxUserInfos): $ReadOnlyArray => values(auxUserInfos) .map( (auxUserInfo: AuxUserInfo) => auxUserInfo.deviceList?.devices ?? [], ) .flat(), ); export { userIDsToRelativeUserInfos, getRelativeMemberInfos, relativeMemberInfoSelectorForMembersOfThread, userInfoSelectorForPotentialMembers, isLoggedIn, isLoggedInToKeyserver, usersWithPersonalThreadSelector, savedEmojiAvatarSelectorForCurrentUser, getRelativeUserIDs, usersWithMissingDeviceListSelector, getForeignPeerDevices, getAllPeerDevices, }; diff --git a/lib/shared/avatar-utils.js b/lib/shared/avatar-utils.js index 268462a23..9e81991d7 100644 --- a/lib/shared/avatar-utils.js +++ b/lib/shared/avatar-utils.js @@ -1,355 +1,355 @@ // @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 { 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'; 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; const barPosition = key.indexOf('|'); if (barPosition !== -1) { key = key.slice(barPosition + 1); } 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'); + if (threadInfo.type === threadTypes.GENESIS_PRIVATE) { + invariant(viewerID, 'viewerID should be set for GENESIS_PRIVATE threads'); return getAvatarForUser(userInfos[viewerID]); } invariant( - threadInfo.type === threadTypes.PERSONAL, - 'threadInfo should be a PERSONAL type', + threadInfo.type === threadTypes.GENESIS_PERSONAL, + 'threadInfo should be a GENESIS_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) { return containingThreadInfo.avatar ? containingThreadInfo.avatar : getDefaultAvatar(containingThreadInfo.id, containingThreadInfo.color); } return getDefaultAvatar(thread.id, thread.color); } 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, useENSResolvedAvatar, }; diff --git a/lib/shared/sidebar-utils.js b/lib/shared/sidebar-utils.js index f0780519a..4647314c9 100644 --- a/lib/shared/sidebar-utils.js +++ b/lib/shared/sidebar-utils.js @@ -1,245 +1,245 @@ // @flow import invariant from 'invariant'; import type { ParserRules } from './markdown.js'; import { getMessageTitle, isInvalidSidebarSource } from './message-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import { createPendingThread, getSingleOtherUser, extractMentionedMembers, useThreadHasPermission, userIsMember, } from './thread-utils.js'; import type { ChatMessageInfoItem } from '../selectors/chat-selectors.js'; import { messageTypes } from '../types/message-types-enum.js'; import type { RobotextMessageInfo, ComposableMessageInfo, } from '../types/message-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { LoggedInUserInfo } from '../types/user-types.js'; import type { GetENSNames } from '../utils/ens-helpers.js'; import { entityTextToRawString, getEntityTextAsString, } from '../utils/entity-text.js'; import type { GetFCNames } from '../utils/farcaster-helpers.js'; import { useSelector } from '../utils/redux-utils.js'; import { trimText } from '../utils/text-utils.js'; type SharedCreatePendingSidebarInput = { +sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, +parentThreadInfo: ThreadInfo, +loggedInUserInfo: LoggedInUserInfo, }; type BaseCreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +messageTitle: string, }; type UserIDAndUsername = { +id: string, +username: 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) { + if (parentThreadType === threadTypes.GENESIS_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, +getFCNames: ?GetFCNames, }; async function createPendingSidebar( input: CreatePendingSidebarInput, ): Promise { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, getENSNames, getFCNames, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = await getEntityTextAsString( messageTitleEntityText, { getENSNames, getFCNames }, { ignoreViewer: true }, ); invariant( messageTitle !== null && messageTitle !== undefined, 'getEntityTextAsString only returns falsey when passed falsey', ); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } function useCanCreateSidebarFromMessage( threadInfo: ThreadInfo, messageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const messageCreatorUserInfo = useSelector( state => state.userStore.userInfos[messageInfo.creator.id], ); const hasCreateSidebarsPermission = useThreadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); if (!hasCreateSidebarsPermission) { return false; } if ( !messageInfo.id || threadInfo.sourceMessageID === messageInfo.id || isInvalidSidebarSource(messageInfo) ) { return false; } const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; return ( !messageCreatorRelationship || !relationshipBlockedInEitherDirection(messageCreatorRelationship) ); } function useSidebarExistsOrCanBeCreated( threadInfo: ThreadInfo, messageItem: ChatMessageInfoItem, ): boolean { const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( threadInfo, messageItem.messageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } export { createUnresolvedPendingSidebar, createPendingSidebar, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, }; diff --git a/lib/shared/state-sync/threads-state-sync-spec.test.js b/lib/shared/state-sync/threads-state-sync-spec.test.js index c8c9b70c1..85dfd4943 100644 --- a/lib/shared/state-sync/threads-state-sync-spec.test.js +++ b/lib/shared/state-sync/threads-state-sync-spec.test.js @@ -1,217 +1,217 @@ // @flow import { threadsStateSyncSpec } from './threads-state-sync-spec.js'; import { defaultCalendarQuery } from '../../types/entry-types.js'; import { processServerRequestsActionType } from '../../types/request-types.js'; import { threadTypes } from '../../types/thread-types-enum.js'; import { getConfig } from '../../utils/config.js'; jest.mock('../../utils/config.js'); describe('Threads state sync spec', () => { describe('find store inconsistencies', () => { it('should find inconsistencies from the same keyserver', () => { const before = { '256|84459': { minimallyEncoded: true, id: '256|84459', - type: threadTypes.PRIVATE, + type: threadTypes.GENESIS_PRIVATE, name: '', description: '', color: 'b8753d', creationTime: 3663, parentThreadID: '256|1', containingThreadID: '256|1', community: '256|1', members: [], roles: {}, currentUser: { role: '256|84460', permissions: '3026f', subscription: { home: true, pushNotifs: true, }, unread: false, minimallyEncoded: true, }, repliesCount: 0, pinnedCount: 0, }, }; const after = { '256|84459': { minimallyEncoded: true, id: '256|84459', - type: threadTypes.PRIVATE, + type: threadTypes.GENESIS_PRIVATE, name: '', description: '', color: 'b8753d', creationTime: 3663, parentThreadID: '256|1', containingThreadID: '256|1', community: '256|1', members: [], roles: {}, currentUser: { role: '256|84460', permissions: '3026f', subscription: { home: true, pushNotifs: true, }, unread: false, minimallyEncoded: true, }, repliesCount: 0, pinnedCount: 0, }, '256|123': { minimallyEncoded: true, id: '256|123', - type: threadTypes.PRIVATE, + type: threadTypes.GENESIS_PRIVATE, name: '', description: '', color: 'b8753d', creationTime: 3663, parentThreadID: '256|1', containingThreadID: '256|1', community: '256|1', members: [], roles: {}, currentUser: { role: '256|84460', permissions: '3026f', subscription: { home: true, pushNotifs: true, }, unread: false, minimallyEncoded: true, }, repliesCount: 0, pinnedCount: 0, }, }; const action = { type: processServerRequestsActionType, payload: { serverRequests: [], calendarQuery: defaultCalendarQuery( getConfig().platformDetails.platform, ), keyserverID: '256', }, }; expect( threadsStateSyncSpec.findStoreInconsistencies(action, before, after) .length, ).toEqual(1); }); it('should ignore inconsistencies from different keyservers', () => { const before = { '256|84459': { minimallyEncoded: true, id: '256|84459', - type: threadTypes.PRIVATE, + type: threadTypes.GENESIS_PRIVATE, name: '', description: '', color: 'b8753d', creationTime: 3663, parentThreadID: '256|1', containingThreadID: '256|1', community: '256|1', members: [], roles: {}, currentUser: { role: '256|84460', permissions: '3026f', subscription: { home: true, pushNotifs: true, }, unread: false, minimallyEncoded: true, }, repliesCount: 0, pinnedCount: 0, }, }; const after = { '256|84459': { minimallyEncoded: true, id: '256|84459', - type: threadTypes.PRIVATE, + type: threadTypes.GENESIS_PRIVATE, name: '', description: '', color: 'b8753d', creationTime: 3663, parentThreadID: '256|1', containingThreadID: '256|1', community: '256|1', members: [], roles: {}, currentUser: { role: '256|84460', permissions: '3026f', subscription: { home: true, pushNotifs: true, }, unread: false, minimallyEncoded: true, }, repliesCount: 0, pinnedCount: 0, }, '123|123': { minimallyEncoded: true, id: '123|123', - type: threadTypes.PRIVATE, + type: threadTypes.GENESIS_PRIVATE, name: '', description: '', color: 'b8753d', creationTime: 3663, parentThreadID: '256|1', containingThreadID: '256|1', community: '256|1', members: [], roles: {}, currentUser: { role: '256|84460', permissions: '3026f', subscription: { home: true, pushNotifs: true, }, unread: false, minimallyEncoded: true, }, repliesCount: 0, pinnedCount: 0, }, }; const action = { type: processServerRequestsActionType, payload: { serverRequests: [], calendarQuery: defaultCalendarQuery( getConfig().platformDetails.platform, ), keyserverID: '256', }, }; expect( threadsStateSyncSpec.findStoreInconsistencies(action, before, after) .length, ).toEqual(0); }); }); }); diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 0ddbfeffd..279bf29fb 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1719 +1,1719 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import _keyBy from 'lodash/fp/keyBy.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omit from 'lodash/fp/omit.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 { extractUserMentionsFromText } from './mention-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import ashoat from '../facts/ashoat.js'; import genesis from '../facts/genesis.js'; import { useLoggedInUserInfo } from '../hooks/account-hooks.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { hasPermission, permissionsToBitmaskHex, threadPermissionsFromBitmaskHex, } from '../permissions/minimally-encoded-thread-permissions.js'; import { specialRoles } from '../permissions/special-roles.js'; import type { SpecialRole } from '../permissions/special-roles.js'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions.js'; import type { ChatThreadItem } from '../selectors/chat-selectors.js'; import { threadInfoSelector, pendingToRealizedThreadIDsSelector, threadInfosSelectorForThreadType, onScreenThreadInfos, } from '../selectors/thread-selectors.js'; import { getRelativeMemberInfos, usersWithPersonalThreadSelector, } from '../selectors/user-selectors.js'; import type { RelativeMemberInfo, RawThreadInfo, MemberInfoWithPermissions, RoleInfo, ThreadInfo, MemberInfoSansPermissions, } 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, threadPermissionsDisabledByBlock, type ThreadPermissionNotAffectedByBlock, } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes, threadTypeIsCommunityRoot, assertThreadType, } from '../types/thread-types-enum.js'; import type { LegacyRawThreadInfo, ClientLegacyRoleInfo, ServerThreadInfo, ServerMemberInfo, 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 { ET, type ThreadEntity, type UserEntity, } from '../utils/entity-text.js'; import { entries, values } from '../utils/objects.js'; import { useSelector } from '../utils/redux-utils.js'; import { firstLine } from '../utils/string-utils.js'; import { pendingThreadIDRegex } from '../utils/validation-utils.js'; function threadHasPermission( threadInfo: ?(ThreadInfo | LegacyRawThreadInfo | RawThreadInfo), permission: ThreadPermissionNotAffectedByBlock, ): 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); } type CommunityRootMembersToRoleType = { +[threadID: ?string]: { +[memberID: string]: ?RoleInfo, }, }; function useCommunityRootMembersToRole( threadInfos: $ReadOnlyArray, ): CommunityRootMembersToRoleType { const communityRootMembersToRole = React.useMemo(() => { const communityThreadInfos = threadInfos.filter(threadInfo => threadTypeIsCommunityRoot(threadInfo.type), ); if (communityThreadInfos.length === 0) { return {}; } const communityRoots = _keyBy('id')(communityThreadInfos); return _mapValues((threadInfo: ThreadInfo) => { const keyedMembers = _keyBy('id')(threadInfo.members); const keyedMembersToRole = _mapValues( (member: MemberInfoWithPermissions | RelativeMemberInfo) => { return member.role ? threadInfo.roles[member.role] : null; }, )(keyedMembers); return keyedMembersToRole; })(communityRoots); }, [threadInfos]); return communityRootMembersToRole; } function useThreadsWithPermission( threadInfos: $ReadOnlyArray, permission: ThreadPermission, ): $ReadOnlyArray { const loggedInUserInfo = useLoggedInUserInfo(); const userInfos = useSelector(state => state.userStore.userInfos); const allThreadInfos = useSelector(state => state.threadStore.threadInfos); const allThreadInfosArray = React.useMemo( () => values(allThreadInfos), [allThreadInfos], ); const communityRootMembersToRole = useCommunityRootMembersToRole(allThreadInfosArray); return React.useMemo(() => { return threadInfos.filter((threadInfo: ThreadInfo) => { const membersToRole = communityRootMembersToRole[threadInfo.id]; const memberHasAdminRole = threadMembersWithoutAddedAdmin( threadInfo, ).some(member => roleIsAdminRole(membersToRole?.[member.id])); if (memberHasAdminRole || !loggedInUserInfo) { return hasPermission(threadInfo.currentUser.permissions, permission); } const threadFrozen = threadIsWithBlockedUserOnlyWithoutAdminRoleCheck( threadInfo, loggedInUserInfo.id, userInfos, false, ); const permissions = threadFrozen ? filterOutDisabledPermissions(threadInfo.currentUser.permissions) : threadInfo.currentUser.permissions; return hasPermission(permissions, permission); }); }, [ threadInfos, communityRootMembersToRole, loggedInUserInfo, userInfos, permission, ]); } function useThreadHasPermission( threadInfo: ?ThreadInfo, permission: ThreadPermission, ): boolean { const threads = useThreadsWithPermission( threadInfo ? [threadInfo] : [], permission, ); return threads.length === 1; } 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 useIsThreadInChatList(threadInfo: ?ThreadInfo): boolean { const threadIsVisible = useThreadHasPermission( threadInfo, threadPermissions.VISIBLE, ); return viewerIsMember(threadInfo) && threadIsVisible; } function useThreadsInChatList( threadInfos: $ReadOnlyArray, ): $ReadOnlyArray { const visibleThreads = useThreadsWithPermission( threadInfos, threadPermissions.VISIBLE, ); return React.useMemo( () => visibleThreads.filter(viewerIsMember), [visibleThreads], ); } 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) { 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 | MemberInfoWithPermissions | MemberInfoSansPermissions | RelativeMemberInfo, >(memberInfos: $ReadOnlyArray, viewerID: ?string): $ReadOnlyArray { return memberInfos.filter( memberInfo => memberInfo.role && memberInfo.id !== viewerID, ); } function threadMembersWithoutAddedAdmin< T: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, >(threadInfo: T): $PropertyType { if (threadInfo.community !== genesis().id) { return threadInfo.members; } const adminID = extractKeyserverIDFromID(threadInfo.id); return threadInfo.members.filter( member => member.id !== adminID || member.role, ); } function threadIsGroupChat(threadInfo: ThreadInfo): boolean { return threadInfo.members.length > 2; } function threadOrParentThreadIsGroupChat( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ) { return threadMembersWithoutAddedAdmin(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, + threadType: threadTypes.GENESIS_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()]; } function pendingThreadType(numberOfOtherMembers: number): 4 | 6 | 7 { if (numberOfOtherMembers === 0) { - return threadTypes.PRIVATE; + return threadTypes.GENESIS_PRIVATE; } else if (numberOfOtherMembers === 1) { - return threadTypes.PERSONAL; + return threadTypes.GENESIS_PERSONAL; } else { return threadTypes.LOCAL; } } function threadTypeCanBePending(threadType: ThreadType): boolean { return ( - threadType === threadTypes.PERSONAL || + threadType === threadTypes.GENESIS_PERSONAL || threadType === threadTypes.LOCAL || threadType === threadTypes.SIDEBAR || - threadType === threadTypes.PRIVATE + threadType === threadTypes.GENESIS_PRIVATE ); } type RawThreadInfoOptions = { +filterThreadEditAvatarPermission?: boolean, +excludePinInfo?: boolean, +filterManageInviteLinksPermission?: boolean, +filterVoicedInAnnouncementChannelsPermission?: boolean, +minimallyEncodePermissions?: boolean, +includeSpecialRoleFieldInRoles?: boolean, +allowAddingUsersToCommunityRoot?: boolean, +filterManageFarcasterChannelTagsPermission?: 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 allowAddingUsersToCommunityRoot = options?.allowAddingUsersToCommunityRoot; const filterManageFarcasterChannelTagsPermission = options?.filterManageFarcasterChannelTagsPermission; const filterThreadPermissions = ( innerThreadPermissions: ThreadPermissionsInfo, ) => { if ( allowAddingUsersToCommunityRoot && (serverThreadInfo.type === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || serverThreadInfo.type === threadTypes.COMMUNITY_ROOT) ) { innerThreadPermissions = { ...innerThreadPermissions, [threadPermissions.ADD_MEMBERS]: { value: true, source: serverThreadInfo.id, }, }; } return _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)) || (filterManageFarcasterChannelTagsPermission && [threadPermissions.MANAGE_FARCASTER_CHANNEL_TAGS].includes(k)), )(innerThreadPermissions); }; const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( 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; } const minimallyEncoded = minimallyEncodeRawThreadInfo(rawThreadInfo); if (shouldIncludeSpecialRoleFieldInRoles) { return minimallyEncoded; } const minimallyEncodedRolesWithoutSpecialRoleField = Object.fromEntries( entries(minimallyEncoded.roles).map(([key, role]) => [ key, { ..._omit('specialRole')(role), isDefault: roleIsDefaultRole(role), }, ]), ); return { ...minimallyEncoded, roles: minimallyEncodedRolesWithoutSpecialRoleField, }; } 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 + threadInfo.type === threadTypes.GENESIS_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: rawThreadInfo.currentUser, 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 + rawThreadInfo.type === threadTypes.GENESIS_PERSONAL || + rawThreadInfo.type === threadTypes.GENESIS_PRIVATE ) { threadInfo = { ...threadInfo, avatar: getUserAvatarForThread(rawThreadInfo, viewerID, userInfos), }; } if (pinnedCount) { threadInfo = { ...threadInfo, pinnedCount }; } return threadInfo; } function filterOutDisabledPermissions(permissionsBitmask: string): string { const decodedPermissions: ThreadPermissionsInfo = threadPermissionsFromBitmaskHex(permissionsBitmask); const updatedPermissions = { ...decodedPermissions, ...disabledPermissions }; const encodedUpdatedPermissions: string = permissionsToBitmaskHex(updatedPermissions); return encodedUpdatedPermissions; } function baseThreadIsWithBlockedUserOnly( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock: boolean, ) { 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 threadIsWithBlockedUserOnly( threadInfo: LegacyRawThreadInfo | RawThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock: boolean, ): boolean { if ( threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo) ) { return false; } return baseThreadIsWithBlockedUserOnly( threadInfo, viewerID, userInfos, checkOnlyViewerBlock, ); } function threadIsWithBlockedUserOnlyWithoutAdminRoleCheck( threadInfo: ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock: boolean, ): boolean { if (threadOrParentThreadIsGroupChat(threadInfo)) { return false; } return baseThreadIsWithBlockedUserOnly( threadInfo, viewerID, userInfos, checkOnlyViewerBlock, ); } function threadFrozenDueToBlock( threadInfo: LegacyRawThreadInfo | RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, false); } function useThreadFrozenDueToViewerBlock( threadInfo: ThreadInfo, communityThreadInfo: ?ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { const communityThreadInfoArray = React.useMemo( () => (communityThreadInfo ? [communityThreadInfo] : []), [communityThreadInfo], ); const communityRootsMembersToRole = useCommunityRootMembersToRole( communityThreadInfoArray, ); const memberToRole = communityRootsMembersToRole[communityThreadInfo?.id]; const memberHasAdminRole = threadMembersWithoutAddedAdmin(threadInfo).some( m => roleIsAdminRole(memberToRole?.[m.id]), ); return React.useMemo(() => { if (memberHasAdminRole) { return false; } return threadIsWithBlockedUserOnlyWithoutAdminRoleCheck( threadInfo, viewerID, userInfos, true, ); }, [memberHasAdminRole, threadInfo, userInfos, viewerID]); } 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: LegacyMemberInfo | MemberInfoWithPermissions | 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 { if (roleInfo?.specialRole === specialRoles.DEFAULT_ROLE) { return true; } return !!(roleInfo && roleInfo.isDefault); } function roleIsAdminRole(roleInfo: ?ClientLegacyRoleInfo | ?RoleInfo): boolean { if (roleInfo?.specialRole === specialRoles.ADMIN_ROLE) { return true; } return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } function threadHasAdminRole( threadInfo: ?( | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo | ServerThreadInfo ), ): boolean { if (!threadInfo) { return false; } let hasSpecialRoleFieldBeenEncountered = false; for (const role of Object.values(threadInfo.roles)) { if (role.specialRole === specialRoles.ADMIN_ROLE) { return true; } if (role.specialRole !== undefined) { hasSpecialRoleFieldBeenEncountered = true; } } if (hasSpecialRoleFieldBeenEncountered) { return false; } return !!_find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: LegacyRawThreadInfo | RawThreadInfo, ) { return ( threadMembersWithoutAddedAdmin(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 = values( threadPermissionsDisabledByBlock, ); 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 ) { 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) { + } else if (threadType === threadTypes.GENESIS_PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { return 'Thread'; - } else if (threadType === threadTypes.PRIVATE) { + } else if (threadType === threadTypes.GENESIS_PRIVATE) { return 'Private'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS ) { return 'Community'; } else { return 'Secret'; } } type ExistingThreadInfoFinderParams = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, }; type ExistingThreadInfoFinder = ( params: ExistingThreadInfoFinderParams, ) => ?ThreadInfo; function useExistingThreadInfoFinder( baseThreadInfo: ?ThreadInfo, ): ExistingThreadInfoFinder { const threadInfos = useSelector(threadInfoSelector); const loggedInUserInfo = useLoggedInUserInfo(); 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; }, [baseThreadInfo, threadInfos, loggedInUserInfo, pendingToRealizedThreadIDs], ); } 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 + threadType === threadTypes.GENESIS_PERSONAL || + threadType === threadTypes.GENESIS_PRIVATE ) { return 'disabled'; } else { return 'optional'; } } 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) { + if (item.threadInfo.type === threadTypes.GENESIS_PRIVATE) { privateThreads.push({ ...item, sidebars: [] }); - } else if (item.threadInfo.type === threadTypes.PERSONAL) { + } else if (item.threadInfo.type === threadTypes.GENESIS_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; } function reorderThreadSearchResults( threadInfos: $ReadOnlyArray, threadSearchResults: $ReadOnlyArray, ): T[] { const privateThreads = []; const personalThreads = []; const otherThreads = []; const threadSearchResultsSet = new Set(threadSearchResults); for (const threadInfo of threadInfos) { if (!threadSearchResultsSet.has(threadInfo.id)) { continue; } - if (threadInfo.type === threadTypes.PRIVATE) { + if (threadInfo.type === threadTypes.GENESIS_PRIVATE) { privateThreads.push(threadInfo); - } else if (threadInfo.type === threadTypes.PERSONAL) { + } else if (threadInfo.type === threadTypes.GENESIS_PERSONAL) { personalThreads.push(threadInfo); } else { otherThreads.push(threadInfo); } } return [...privateThreads, ...personalThreads, ...otherThreads]; } function useAvailableThreadMemberActions( memberInfo: RelativeMemberInfo, threadInfo: ThreadInfo, canEdit: ?boolean = true, ): $ReadOnlyArray<'change_role' | 'remove_user'> { const canRemoveMembers = useThreadHasPermission( threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = useThreadHasPermission( threadInfo, threadPermissions.CHANGE_ROLE, ); return React.useMemo(() => { const { role } = memberInfo; if (!canEdit || !role) { return []; } 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; }, [canChangeRoles, canEdit, canRemoveMembers, memberInfo, threadInfo]); } 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.members, threadInfo.roles]); } function useRoleNamesToSpecialRole(threadInfo: ThreadInfo): { +[roleName: string]: ?SpecialRole, } { return React.useMemo(() => { const roleNamesToSpecialRole: { [roleName: string]: ?SpecialRole } = {}; values(threadInfo.roles).forEach(role => { if (roleNamesToSpecialRole[role.name] !== undefined) { return; } if (roleIsDefaultRole(role)) { roleNamesToSpecialRole[role.name] = specialRoles.DEFAULT_ROLE; } else if (roleIsAdminRole(role)) { roleNamesToSpecialRole[role.name] = specialRoles.ADMIN_ROLE; } else { roleNamesToSpecialRole[role.name] = null; } }); return roleNamesToSpecialRole; }, [threadInfo.roles]); } 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.roles]); } 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, + threadTypes.GENESIS_PRIVATE, ); const privateThreadInfos = useSelector(privateThreadInfosSelector); const personalThreadInfosSelector = threadInfosSelectorForThreadType( - threadTypes.PERSONAL, + threadTypes.GENESIS_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]; }), ); } function useOnScreenEntryEditableThreadInfos(): $ReadOnlyArray { const visibleThreadInfos = useSelector(onScreenThreadInfos); const editableVisibleThreadInfos = useThreadsWithPermission( visibleThreadInfos, threadPermissions.EDIT_ENTRIES, ); return editableVisibleThreadInfos; } export { threadHasPermission, useCommunityRootMembersToRole, useThreadHasPermission, viewerIsMember, threadInChatList, useIsThreadInChatList, useThreadsInChatList, threadIsTopLevel, threadIsChannel, threadIsSidebar, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadOtherMembers, threadIsGroupChat, threadIsPending, threadIsPendingSidebar, getSingleOtherUser, getPendingThreadID, parsePendingThreadID, createPendingThread, extractNewMentionedParentMembers, pendingThreadType, filterOutDisabledPermissions, threadFrozenDueToBlock, useThreadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, threadUIName, threadInfoFromRawThreadInfo, threadTypeDescriptions, memberHasAdminPowers, roleIsDefaultRole, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadNoun, threadLabel, useExistingThreadInfoFinder, getThreadTypeParentRequirement, checkIfDefaultMembersAreVoiced, draftKeySuffix, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, reorderThreadSearchResults, useAvailableThreadMemberActions, threadMembersWithoutAddedAdmin, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, useRoleMemberCountsForCommunity, useRoleNamesToSpecialRole, useRoleUserSurfacedPermissions, getThreadsToDeleteText, useUserProfileThreadInfo, assertAllThreadInfosAreLegacy, useOnScreenEntryEditableThreadInfos, extractMentionedMembers, }; diff --git a/lib/shared/thread-utils.test.js b/lib/shared/thread-utils.test.js index db46df561..e1aae0c54 100644 --- a/lib/shared/thread-utils.test.js +++ b/lib/shared/thread-utils.test.js @@ -1,199 +1,199 @@ // @flow import { parsePendingThreadID, threadInfoFromRawThreadInfo, } from './thread-utils.js'; import { threadInfoValidator } from '../permissions/minimally-encoded-thread-permissions-validators.js'; import type { 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'; describe('parsePendingThreadID(pendingThreadID: string)', () => { it('should return correct data for real pending sidebar ID', () => { const sidebarResult = { threadType: threadTypes.SIDEBAR, memberIDs: [], sourceMessageID: '12345', }; expect(parsePendingThreadID('pending/sidebar/12345')).toStrictEqual( sidebarResult, ); const sidebarResultWithNewSchema = { threadType: threadTypes.SIDEBAR, memberIDs: [], sourceMessageID: '789|12345', }; expect(parsePendingThreadID('pending/sidebar/789|12345')).toStrictEqual( sidebarResultWithNewSchema, ); }); it('should return correct data for real pending sidebar ID', () => { const pendingPersonalResult = { - threadType: threadTypes.PERSONAL, + threadType: threadTypes.GENESIS_PERSONAL, memberIDs: ['83810', '86622'], sourceMessageID: null, }; expect(parsePendingThreadID('pending/type6/83810+86622')).toStrictEqual( pendingPersonalResult, ); const pendingCommunityOpenResult = { threadType: threadTypes.COMMUNITY_OPEN_SUBTHREAD, memberIDs: ['83810', '86622', '83889'], sourceMessageID: null, }; expect( parsePendingThreadID('pending/type3/83810+86622+83889'), ).toStrictEqual(pendingCommunityOpenResult); }); it('should return null when there are missing information in ID', () => { expect(parsePendingThreadID('pending/type4/')).toBeNull(); expect(parsePendingThreadID('type12/83810+86622')).toBeNull(); expect(parsePendingThreadID('pending/83810')).toBeNull(); expect(parsePendingThreadID('pending')).toBeNull(); expect(parsePendingThreadID('')).toBeNull(); expect(parsePendingThreadID('pending/something/12345')).toBeNull(); }); it('should return null when the format is invalid', () => { expect(parsePendingThreadID('someothertext/type1/12345')).toBeNull(); expect(parsePendingThreadID('pending/type6/12312+++11+12')).toBeNull(); expect(parsePendingThreadID('pending/type3/83810+')).toBeNull(); }); it('should throw invariant violation when thread type is invalid ', () => { expect(() => parsePendingThreadID('pending/type123/12345')).toThrowError( 'number is not ThreadType enum', ); }); }); const rawThreadInfo: RawThreadInfo = { id: '1', type: threadTypes.GENESIS, 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.', color: 'c85000', creationTime: 1702415956354, parentThreadID: null, repliesCount: 0, containingThreadID: null, community: null, pinnedCount: 0, minimallyEncoded: true, members: [ { id: '256', role: '83796', permissions: '3f73ff', isSender: false, minimallyEncoded: true, }, { id: '83809', role: '83795', permissions: '3', isSender: false, minimallyEncoded: true, }, ], roles: { '83795': { id: '83795', name: 'Members', permissions: ['000', '010', '005', '015', '0a7'], minimallyEncoded: true, }, '83796': { id: '83796', name: 'Admins', permissions: [ '000', '010', '020', '100', '110', '030', '040', '060', '050', '120', '080', '090', '0c0', '070', '0d0', '0e0', '130', '140', '150', '004', '014', '0a6', '0a8', '024', '034', '044', '064', '054', '124', '086', '096', '0c4', '074', '0b4', '0d4', '0e4', '134', '156', ], minimallyEncoded: true, }, }, currentUser: { role: '83795', permissions: '3', subscription: { home: true, pushNotifs: true, }, unread: false, minimallyEncoded: true, }, }; const userInfos: UserInfos = { '5': { id: '5', username: 'commbot', }, '256': { id: '256', username: 'ashoat', }, '83809': { id: '83809', username: 'atul', avatar: { type: 'emoji', emoji: '😲', color: '4b87aa', }, }, }; describe('threadInfoFromRawThreadInfo', () => { it('should return correctly formed ThreadInfo from RawThreadInfo', () => { const threadInfo = threadInfoFromRawThreadInfo( rawThreadInfo, null, userInfos, ); expect(threadInfoValidator.is(threadInfo)).toBe(true); }); }); diff --git a/lib/types/thread-types-enum.js b/lib/types/thread-types-enum.js index 95cf7e9c6..548ddb9cd 100644 --- a/lib/types/thread-types-enum.js +++ b/lib/types/thread-types-enum.js @@ -1,96 +1,98 @@ // @flow import invariant from 'invariant'; import type { TRefinement } from 'tcomb'; import { values } from '../utils/objects.js'; import { tNumEnum } from '../utils/validation-utils.js'; export const thinThreadTypes = Object.freeze({ //OPEN: 0, (DEPRECATED) //CLOSED: 1, (DEPRECATED) //SECRET: 2, (DEPRECATED) // has parent, not top-level (appears under parent in inbox), and visible to // all members of parent SIDEBAR: 5, // canonical thread for each pair of users. represents the friendship - PERSONAL: 6, + // created under GENESIS. being deprecated in favor of PERSONAL + GENESIS_PERSONAL: 6, // canonical thread for each single user - PRIVATE: 7, + // created under GENESIS. being deprecated in favor of PRIVATE + GENESIS_PRIVATE: 7, // local "thick" thread (outside of community). no parent, can only have // sidebar children. currently a proxy for COMMUNITY_SECRET_SUBTHREAD until we // launch actual E2E LOCAL: 4, // aka "org". no parent, top-level, has admin COMMUNITY_ROOT: 8, // like COMMUNITY_ROOT, but members aren't voiced COMMUNITY_ANNOUNCEMENT_ROOT: 9, // an open subthread. has parent, top-level (not sidebar), and visible to all // members of parent. root ancestor is a COMMUNITY_ROOT COMMUNITY_OPEN_SUBTHREAD: 3, // like COMMUNITY_SECRET_SUBTHREAD, but members aren't voiced COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD: 10, // a secret subthread. optional parent, top-level (not sidebar), visible only // to its members. root ancestor is a COMMUNITY_ROOT COMMUNITY_SECRET_SUBTHREAD: 4, // like COMMUNITY_SECRET_SUBTHREAD, but members aren't voiced COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD: 11, // like COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD, but you can't leave GENESIS: 12, }); export type ThinThreadType = $Values; export type ThreadType = ThinThreadType; export const threadTypes = Object.freeze({ ...thinThreadTypes, }); export function assertThreadType(threadType: number): ThreadType { invariant( threadType === 3 || threadType === 4 || threadType === 5 || threadType === 6 || threadType === 7 || threadType === 8 || threadType === 9 || threadType === 10 || threadType === 11 || threadType === 12, 'number is not ThreadType enum', ); return threadType; } export const threadTypeValidator: TRefinement = tNumEnum( values(threadTypes), ); export const communityThreadTypes: $ReadOnlyArray = Object.freeze([ threadTypes.COMMUNITY_ROOT, threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT, threadTypes.GENESIS, ]); export const announcementThreadTypes: $ReadOnlyArray = Object.freeze([ threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT, threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD, threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD, ]); export const communitySubthreads: $ReadOnlyArray = Object.freeze([ threadTypes.COMMUNITY_OPEN_SUBTHREAD, threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD, threadTypes.COMMUNITY_SECRET_SUBTHREAD, threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD, ]); export function threadTypeIsCommunityRoot(threadType: ThreadType): boolean { return communityThreadTypes.includes(threadType); } export function threadTypeIsAnnouncementThread( threadType: ThreadType, ): boolean { return announcementThreadTypes.includes(threadType); } diff --git a/lib/types/validation.test.js b/lib/types/validation.test.js index c5d9c33df..4306aa837 100644 --- a/lib/types/validation.test.js +++ b/lib/types/validation.test.js @@ -1,874 +1,874 @@ // @flow import _findKey from 'lodash/fp/findKey.js'; import { rawEntryInfoValidator } from './entry-types.js'; import { imageValidator, videoValidator, mediaValidator, } from './media-types.js'; import { messageTypes } from './message-types-enum.js'; import { activityUpdateResponseServerSocketMessageValidator, apiResponseServerSocketMessageValidator, authErrorServerSocketMessageValidator, errorServerSocketMessageValidator, messagesServerSocketMessageValidator, pongServerSocketMessageValidator, serverRequestsServerSocketMessageValidator, serverSocketMessageTypes, serverStateSyncServerSocketMessageValidator, serverUpdatesServerSocketMessageValidator, } from './socket-types.js'; import { threadTypes } from './thread-types-enum.js'; import { legacyRawThreadInfoValidator } from './thread-types.js'; import { updateTypes } from './update-types-enum.js'; import { messageSpecs } from '../shared/messages/message-specs.js'; import { updateSpecs } from '../shared/updates/update-specs.js'; import { values } from '../utils/objects.js'; describe('media validation', () => { const photo = { id: '92696', type: 'photo', uri: 'http://0.0.0.0:3000/comm/upload/92696/0fb272bd1c75d976', dimensions: { width: 340, height: 288, }, }; const video = { type: 'video', id: '92769', uri: 'http://0.0.0.0:3000/comm/upload/92769/4bcc6987b25b2f66', dimensions: { width: 480, height: 270, }, thumbnailID: '92770', thumbnailURI: 'http://0.0.0.0:3000/comm/upload/92770/d56466051dcef1db', }; it('should validate correct media', () => { expect(mediaValidator.is(photo)).toBe(true); expect(imageValidator.is(photo)).toBe(true); expect(mediaValidator.is(video)).toBe(true); expect(videoValidator.is(video)).toBe(true); }); it('should not validate incorrect media', () => { expect(imageValidator.is(video)).toBe(false); expect(videoValidator.is(photo)).toBe(false); expect(mediaValidator.is({ ...photo, type: undefined })).toBe(false); expect(mediaValidator.is({ ...video, dimensions: undefined })).toBe(false); }); }); const messages = [ { type: messageTypes.TEXT, threadID: '83859', creatorID: '83853', time: 1682077048858, text: 'text', localID: 'local1', id: '92837', }, { type: messageTypes.CREATE_THREAD, id: '83876', threadID: '83859', time: 1673561105839, creatorID: '83853', initialThreadState: { type: 6, name: null, parentThreadID: '1', color: '57697f', memberIDs: ['256', '83853'], }, }, { type: messageTypes.ADD_MEMBERS, id: '4754380', threadID: '4746046', time: 1680179819346, creatorID: '256', addedUserIDs: ['518252', '1329299', '1559042'], }, { type: messageTypes.CREATE_SUB_THREAD, threadID: '87111', creatorID: '83928', time: 1682083573756, childThreadID: '92993', id: '93000', }, { type: messageTypes.CHANGE_SETTINGS, threadID: '83859', creatorID: '83853', time: 1682082984605, field: 'color', value: 'b8753d', id: '92880', }, { type: messageTypes.REMOVE_MEMBERS, threadID: '92993', creatorID: '83928', time: 1682083613415, removedUserIDs: ['83890'], id: '93012', }, { type: messageTypes.CHANGE_ROLE, threadID: '85027', creatorID: '256', time: 1632393331694, userIDs: ['85081'], newRole: 'role', id: '85431', }, { type: messageTypes.LEAVE_THREAD, id: '93027', threadID: '92993', time: 1682083651037, creatorID: '83928', }, { type: messageTypes.JOIN_THREAD, threadID: '92993', creatorID: '83928', time: 1682083678595, id: '93035', }, { type: messageTypes.CREATE_ENTRY, threadID: '84695', creatorID: '83928', time: 1682083217395, entryID: '92917', date: '2023-04-02', text: 'text', id: '92920', }, { type: messageTypes.EDIT_ENTRY, threadID: '84695', creatorID: '83928', time: 1682083374471, entryID: '92917', date: '2023-04-02', text: 'text', id: '92950', }, { type: messageTypes.DELETE_ENTRY, threadID: '86033', creatorID: '83928', time: 1682083220296, entryID: '92904', date: '2023-04-02', text: 'text', id: '92932', }, { type: messageTypes.RESTORE_ENTRY, id: '92962', threadID: '86033', time: 1682083414244, creatorID: '83928', entryID: '92904', date: '2023-04-02', text: 'text', }, { type: messageTypes.UNSUPPORTED, threadID: '87080', creatorID: '256', time: 1640733462322, robotext: 'unsupported message', unsupportedMessageInfo: { type: 105, threadID: '97489', creatorID: '256', time: 1640773011289, id: '97672', }, id: '97730', }, { type: messageTypes.IMAGES, threadID: '92796', creatorID: '83928', time: 1682083469079, media: [ { id: '92974', uri: 'http://0.0.0.0:3000/comm/upload/92974/ff3d02ded71e2762', type: 'photo', dimensions: { width: 220, height: 220, }, }, ], localID: 'local0', id: '92976', }, { type: messageTypes.MULTIMEDIA, threadID: '89644', creatorID: '83853', time: 1682076177257, media: [ { type: 'video', id: '92769', uri: 'http://0.0.0.0:3000/comm/upload/92769/4bcc6987b25b2f66', dimensions: { width: 480, height: 270, }, thumbnailID: '92770', thumbnailURI: 'http://0.0.0.0:3000/comm/upload/92770/d56466051dcef1db', }, ], id: '92771', }, { type: messageTypes.LEGACY_UPDATE_RELATIONSHIP, threadID: '92796', creatorID: '83928', targetID: '83853', time: 1682083716312, operation: 'request_sent', id: '93039', }, { type: messageTypes.SIDEBAR_SOURCE, threadID: '93044', creatorID: '83928', time: 1682083756831, sourceMessage: { type: 0, id: '92816', threadID: '92796', time: 1682076737518, creatorID: '83928', text: 'text', }, id: '93049', }, { type: messageTypes.CREATE_SIDEBAR, threadID: '93044', creatorID: '83928', time: 1682083756831, sourceMessageAuthorID: '83928', initialThreadState: { name: 'text', parentThreadID: '92796', color: 'aa4b4b', memberIDs: ['83853', '83928'], }, id: '93050', }, { type: messageTypes.REACTION, threadID: '86033', localID: 'local8', creatorID: '83928', time: 1682083295820, targetMessageID: '91607', reaction: '😂', action: 'add_reaction', id: '92943', }, { type: messageTypes.EDIT_MESSAGE, threadID: '86033', creatorID: '83928', time: 1682083295820, targetMessageID: '91607', text: 'text', id: '92943', }, { type: messageTypes.TOGGLE_PIN, threadID: '86033', targetMessageID: '91607', action: 'pin', pinnedContent: 'text', creatorID: '83928', time: 1682083295820, id: '92943', }, ]; describe('message validation', () => { for (const validatorMessageTypeName in messageTypes) { const validatorMessageType = messageTypes[validatorMessageTypeName]; const validator = messageSpecs[validatorMessageType].validator; for (const message of messages) { const messageTypeName = _findKey(e => e === message.type)(messageTypes); if (validatorMessageType === message.type) { it(`${validatorMessageTypeName} should validate ${messageTypeName}`, () => { expect(validator.is(message)).toBe(true); }); } else if ( !( (validatorMessageType === messageTypes.IMAGES && message.type === messageTypes.MULTIMEDIA) || (validatorMessageType === messageTypes.MULTIMEDIA && message.type === messageTypes.IMAGES) || (validatorMessageType === messageTypes.UPDATE_RELATIONSHIP && message.type === messageTypes.LEGACY_UPDATE_RELATIONSHIP) || (validatorMessageType === messageTypes.LEGACY_UPDATE_RELATIONSHIP && message.type === messageTypes.UPDATE_RELATIONSHIP) ) ) { it(`${validatorMessageTypeName} shouldn't validate ${messageTypeName}`, () => { expect(validator.is(message)).toBe(false); }); } } } }); const thread = { id: '85171', - type: threadTypes.PERSONAL, + type: threadTypes.GENESIS_PERSONAL, name: '', description: '', color: '6d49ab', creationTime: 1675887298557, parentThreadID: '1', members: [ { id: '256', role: null, permissions: { know_of: { value: true, source: '1', }, visible: { value: true, source: '1', }, voiced: { value: true, source: '1', }, edit_entries: { value: true, source: '1', }, edit_thread: { value: true, source: '1', }, edit_thread_description: { value: true, source: '1', }, edit_thread_color: { value: true, source: '1', }, delete_thread: { value: true, source: '1', }, create_subthreads: { value: true, source: '1', }, create_sidebars: { value: true, source: '1', }, join_thread: { value: true, source: '1', }, edit_permissions: { value: true, source: '1', }, add_members: { value: true, source: '1', }, remove_members: { value: true, source: '1', }, change_role: { value: true, source: '1', }, leave_thread: { value: false, source: null, }, react_to_message: { value: false, source: null, }, edit_message: { value: false, source: null, }, manage_pins: { value: true, source: '1', }, }, isSender: false, }, { id: '83853', role: '85172', permissions: { know_of: { value: true, source: '85171', }, visible: { value: true, source: '85171', }, voiced: { value: true, source: '85171', }, edit_entries: { value: true, source: '85171', }, edit_thread: { value: true, source: '85171', }, edit_thread_description: { value: true, source: '85171', }, edit_thread_color: { value: true, source: '85171', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: '85171', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: '85171', }, edit_message: { value: true, source: '85171', }, manage_pins: { value: false, source: null, }, }, isSender: true, }, ], roles: { '85172': { id: '85172', name: 'Members', permissions: { know_of: true, visible: true, voiced: true, react_to_message: true, edit_message: true, edit_entries: true, edit_thread: true, edit_thread_color: true, edit_thread_description: true, create_sidebars: true, descendant_open_know_of: true, descendant_open_visible: true, child_open_join_thread: true, }, isDefault: true, }, }, currentUser: { role: '85172', permissions: { know_of: { value: true, source: '85171', }, visible: { value: true, source: '85171', }, voiced: { value: true, source: '85171', }, edit_entries: { value: true, source: '85171', }, edit_thread: { value: true, source: '85171', }, edit_thread_description: { value: true, source: '85171', }, edit_thread_color: { value: true, source: '85171', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: '85171', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: '85171', }, edit_message: { value: true, source: '85171', }, manage_pins: { value: false, source: null, }, }, subscription: { home: true, pushNotifs: true, }, unread: false, }, repliesCount: 0, containingThreadID: '1', community: '1', pinnedCount: 0, }; describe('thread validation', () => { it('should validate correct thread', () => { expect(legacyRawThreadInfoValidator.is(thread)).toBe(true); }); it('should not validate incorrect thread', () => { expect( legacyRawThreadInfoValidator.is({ ...thread, creationTime: undefined }), ).toBe(false); }); }); const entry = { id: '92860', threadID: '85068', text: 'text', year: 2023, month: 4, day: 2, creationTime: 1682082939882, creatorID: '83853', deleted: false, }; describe('entry validation', () => { it('should validate correct entry', () => { expect(rawEntryInfoValidator.is(entry)).toBe(true); }); it('should not validate incorrect entry', () => { expect(rawEntryInfoValidator.is({ ...entry, threadID: 0 })).toBe(false); }); }); const updates = [ { type: updateTypes.DELETE_ACCOUNT, id: '98424', time: 1640870111106, deletedUserID: '98262', }, { type: updateTypes.UPDATE_THREAD, id: '97948', time: 1640868525494, threadInfo: thread, }, { type: updateTypes.UPDATE_THREAD_READ_STATUS, id: '98002', time: 1640869373326, threadID: '83794', unread: true, }, { type: updateTypes.DELETE_THREAD, id: '98208', time: 1640869773339, threadID: '97852', }, { type: updateTypes.JOIN_THREAD, id: '98126', time: 1640869494461, threadInfo: thread, rawMessageInfos: messages, truncationStatus: 'exhaustive', rawEntryInfos: [entry], }, { type: updateTypes.BAD_DEVICE_TOKEN, id: '98208', time: 1640869773495, deviceToken: 'some-device-token', }, { type: updateTypes.UPDATE_ENTRY, id: '98233', time: 1640869844908, entryInfo: entry, }, { type: updateTypes.UPDATE_CURRENT_USER, id: '98237', time: 1640869934058, currentUserInfo: { id: '256', username: 'ashoat', }, }, { type: updateTypes.UPDATE_USER, id: '97988', time: 1640869211822, updatedUserID: '86565', }, ]; describe('server update validation', () => { for (const validatorUpdateType of values(updateTypes)) { const validator = updateSpecs[validatorUpdateType].infoValidator; const validatorUpdateTypeName = _findKey( e => e === Number(validatorUpdateType), )(updateTypes); for (const update of updates) { const updateTypeName = _findKey(e => e === update.type)(updateTypes); if (Number(validatorUpdateType) === update.type) { it(`${validatorUpdateTypeName} should validate ${updateTypeName}`, () => { expect(validator.is(update)).toBe(true); }); } else { it(`${validatorUpdateTypeName} shouldn't validate ${updateTypeName}`, () => { expect(validator.is(update)).toBe(false); }); } } } }); describe('socket message validation', () => { const socketMessages = [ { type: serverSocketMessageTypes.STATE_SYNC, responseTo: 0, payload: { type: 1, messagesResult: { rawMessageInfos: messages, truncationStatuses: { '86033': 'unchanged' }, currentAsOf: 1683296863468, }, updatesResult: { newUpdates: updates, currentAsOf: 1683296863489, }, deltaEntryInfos: [], deletedEntryIDs: [], userInfos: [], }, }, { type: serverSocketMessageTypes.REQUESTS, payload: { serverRequests: [ { type: 6, hashesToCheck: { threadInfos: 3311950643, entryInfos: 3191324567, currentUserInfo: 820850779, userInfos: 707653884, }, }, ], }, }, { type: serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, responseTo: 194, payload: { unfocusedToUnread: [] }, }, { type: serverSocketMessageTypes.PONG, responseTo: 190 }, { type: 6, payload: { updatesResult: { currentAsOf: 1683298141720, newUpdates: [ { type: 1, id: '94428', time: 1683298141720, threadInfo: thread, }, ], }, userInfos: [], }, }, { type: serverSocketMessageTypes.MESSAGES, payload: { messagesResult: { rawMessageInfos: messages, truncationStatuses: { '86033': 'unchanged' }, currentAsOf: 1683298141707, }, }, }, { type: serverSocketMessageTypes.API_RESPONSE, responseTo: 209, payload: { rawMessageInfos: messages, truncationStatuses: { '1': 'exhaustive' }, userInfos: {}, }, }, ]; const validatorByMessageType = { [serverSocketMessageTypes.STATE_SYNC]: serverStateSyncServerSocketMessageValidator, [serverSocketMessageTypes.REQUESTS]: serverRequestsServerSocketMessageValidator, [serverSocketMessageTypes.ERROR]: errorServerSocketMessageValidator, [serverSocketMessageTypes.AUTH_ERROR]: authErrorServerSocketMessageValidator, [serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE]: activityUpdateResponseServerSocketMessageValidator, [serverSocketMessageTypes.PONG]: pongServerSocketMessageValidator, [serverSocketMessageTypes.UPDATES]: serverUpdatesServerSocketMessageValidator, [serverSocketMessageTypes.MESSAGES]: messagesServerSocketMessageValidator, [serverSocketMessageTypes.API_RESPONSE]: apiResponseServerSocketMessageValidator, }; for (const validatorMessageType in validatorByMessageType) { const validator = validatorByMessageType[validatorMessageType]; const validatorMessageTypeName = _findKey( e => e === Number(validatorMessageType), )(serverSocketMessageTypes); for (const message of socketMessages) { const messageTypeName = _findKey(e => e === message.type)( serverSocketMessageTypes, ); if (Number(validatorMessageType) === message.type) { it(`${validatorMessageTypeName} should validate ${messageTypeName}`, () => { expect(validator.is(message)).toBe(true); }); } else { it(`${validatorMessageTypeName} shouldn't validate ${messageTypeName}`, () => { expect(validator.is(message)).toBe(false); }); } } } }); diff --git a/lib/utils/entity-text.js b/lib/utils/entity-text.js index ad22bb879..e96869845 100644 --- a/lib/utils/entity-text.js +++ b/lib/utils/entity-text.js @@ -1,684 +1,684 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import t, { type TInterface, type TUnion } from 'tcomb'; import type { GetENSNames } from './ens-helpers.js'; import type { GetFCNames } from './farcaster-helpers.js'; import { tID, tShape, tString, tUserID } from './validation-utils.js'; import { useENSNames } from '../hooks/ens-cache.js'; import { useFCNames } from '../hooks/fc-cache.js'; import { threadNoun } from '../shared/thread-utils.js'; import { stringForUser } from '../shared/user-utils.js'; import type { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { type ThreadType, threadTypes, threadTypeValidator, } from '../types/thread-types-enum.js'; import { basePluralize } from '../utils/text-utils.js'; export type UserEntity = { +type: 'user', +id: string, +username?: ?string, +isViewer?: ?boolean, +possessive?: ?boolean, // eg. `user's` instead of `user` }; export const userEntityValidator: TInterface = tShape({ type: tString('user'), id: tUserID, username: t.maybe(t.String), isViewer: t.maybe(t.Boolean), possessive: t.maybe(t.Boolean), }); // Comments explain how thread name will appear from user4's perspective export type ThreadEntity = | { +type: 'thread', +id: string, +name?: ?string, // displays threadInfo.name if set, or 'user1, user2, and user3' +display: 'uiName', // If uiName is EntityText, then at render time ThreadEntity will be // replaced with a pluralized list of uiName's UserEntities +uiName: $ReadOnlyArray | string, // If name isn't set and uiName is an array with only the viewer, then // just_you_string displays "just you" but viewer_username displays the // viewer's ENS-resolved username. Defaults to just_you_string +ifJustViewer?: 'just_you_string' | 'viewer_username', } | { +type: 'thread', +id: string, +name?: ?string, // displays threadInfo.name if set, or eg. 'this thread' or 'this chat' +display: 'shortName', +threadType?: ?ThreadType, +parentThreadID?: ?string, +alwaysDisplayShortName?: ?boolean, // don't default to name +subchannel?: ?boolean, // short name should be "subchannel" +possessive?: ?boolean, // eg. `this thread's` instead of `this thread` }; export const threadEntityValidator: TUnion = t.union([ tShape({ type: tString('thread'), id: tID, name: t.maybe(t.String), display: tString('uiName'), uiName: t.union([t.list(userEntityValidator), t.String]), ifJustViewer: t.maybe(t.enums.of(['just_you_string', 'viewer_username'])), }), tShape({ type: tString('thread'), id: tID, name: t.maybe(t.String), display: tString('shortName'), threadType: t.maybe(threadTypeValidator), parentThreadID: t.maybe(tID), alwaysDisplayShortName: t.maybe(t.Boolean), subchannel: t.maybe(t.Boolean), possessive: t.maybe(t.Boolean), }), ]); type ColorEntity = { +type: 'color', +hex: string, }; type FarcasterUserEntity = { +type: 'farcaster_user', +fid: string, +farcasterUsername?: ?string, }; type EntityTextComponent = | UserEntity | ThreadEntity | ColorEntity | FarcasterUserEntity | string; export type EntityText = $ReadOnlyArray; const entityTextFunction = ( strings: $ReadOnlyArray, ...entities: $ReadOnlyArray ) => { const result: EntityTextComponent[] = []; for (let i = 0; i < strings.length; i++) { const str = strings[i]; if (str) { result.push(str); } const entity = entities[i]; if (!entity) { continue; } if (typeof entity === 'string') { const lastResult = result.length > 0 && result[result.length - 1]; if (typeof lastResult === 'string') { result[result.length - 1] = lastResult + entity; } else { result.push(entity); } } else if (Array.isArray(entity)) { const [firstEntity, ...restOfEntity] = entity; const lastResult = result.length > 0 && result[result.length - 1]; if (typeof lastResult === 'string' && typeof firstEntity === 'string') { result[result.length - 1] = lastResult + firstEntity; } else if (firstEntity) { result.push(firstEntity); } result.push(...restOfEntity); } else { result.push(entity); } } return result; }; // defaults to shortName type EntityTextThreadInput = | { +display: 'uiName', +threadInfo: ThreadInfo, } | { +display?: 'shortName', +threadInfo: RawThreadInfo | ThreadInfo, +subchannel?: ?boolean, +possessive?: ?boolean, } | { +display: 'alwaysDisplayShortName', +threadInfo: RawThreadInfo | ThreadInfo, +possessive?: ?boolean, } | { +display: 'alwaysDisplayShortName', +threadID: string, +parentThreadID?: ?string, +threadType?: ?ThreadType, +possessive?: ?boolean, }; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return entityTextFunction.thread = (input: EntityTextThreadInput) => { if (input.display === 'uiName') { const { threadInfo } = input; if (typeof threadInfo.uiName !== 'string') { return threadInfo.uiName; } return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: threadInfo.uiName, }; } if (input.display === 'alwaysDisplayShortName' && input.threadID) { const { threadID, threadType, parentThreadID, possessive } = input; return { type: 'thread', id: threadID, name: undefined, display: 'shortName', threadType, parentThreadID, alwaysDisplayShortName: true, possessive, }; } else if (input.display === 'alwaysDisplayShortName' && input.threadInfo) { const { threadInfo, possessive } = input; return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'shortName', threadType: threadInfo.type, parentThreadID: threadInfo.parentThreadID, alwaysDisplayShortName: true, possessive, }; } else if (input.display === 'shortName' || !input.display) { const { threadInfo, subchannel, possessive } = input; return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'shortName', threadType: threadInfo.type, parentThreadID: threadInfo.parentThreadID, subchannel, possessive, }; } invariant( false, `ET.thread passed unexpected display type: ${input.display}`, ); }; type EntityTextUserInput = { +userInfo: { +id: string, +username?: ?string, +isViewer?: ?boolean, ... }, +possessive?: ?boolean, }; entityTextFunction.user = (input: EntityTextUserInput) => ({ type: 'user', id: input.userInfo.id, username: input.userInfo.username, isViewer: input.userInfo.isViewer, possessive: input.possessive, }); type EntityTextColorInput = { +hex: string }; entityTextFunction.color = (input: EntityTextColorInput) => ({ type: 'color', hex: input.hex, }); type EntityTextFarcasterUserInput = { +fid: string }; entityTextFunction.fcUser = (input: EntityTextFarcasterUserInput) => ({ type: 'farcaster_user', fid: input.fid, }); // ET is a JS tag function used in template literals, eg. ET`something` // It allows you to compose raw text and "entities" together type EntityTextFunction = (( strings: $ReadOnlyArray, ...entities: $ReadOnlyArray ) => EntityText) & { +thread: EntityTextThreadInput => ThreadEntity, +user: EntityTextUserInput => UserEntity, +color: EntityTextColorInput => ColorEntity, +fcUser: EntityTextFarcasterUserInput => FarcasterUserEntity, ... }; const ET: EntityTextFunction = entityTextFunction; type MakePossessiveInput = { +str: string, +isViewer?: ?boolean }; function makePossessive(input: MakePossessiveInput) { if (input.isViewer) { return 'your'; } return `${input.str}’s`; } function getNameForThreadEntity( entity: ThreadEntity, params?: ?EntityTextToRawStringParams, ): string { const { name: userGeneratedName, display } = entity; if (entity.display === 'uiName') { if (userGeneratedName) { return userGeneratedName; } const { uiName } = entity; if (typeof uiName === 'string') { return uiName; } let userEntities = uiName; if (!params?.ignoreViewer) { const viewerFilteredUserEntities = userEntities.filter( innerEntity => !innerEntity.isViewer, ); if (viewerFilteredUserEntities.length > 0) { userEntities = viewerFilteredUserEntities; } else if (entity.ifJustViewer === 'viewer_username') { // We pass ignoreViewer to entityTextToRawString in order // to prevent it from rendering the viewer as "you" params = { ...params, ignoreViewer: true }; } else { return 'just you'; } } const pluralized = pluralizeEntityText( userEntities.map(innerEntity => [innerEntity]), ); return entityTextToRawString(pluralized, params); } invariant( entity.display === 'shortName', `getNameForThreadEntity can't handle thread entity display ${display}`, ); let { name } = entity; if (!name || entity.alwaysDisplayShortName) { - const threadType = entity.threadType ?? threadTypes.PERSONAL; + const threadType = entity.threadType ?? threadTypes.GENESIS_PERSONAL; const { parentThreadID } = entity; const noun = entity.subchannel ? 'subchannel' : threadNoun(threadType, parentThreadID); if (entity.id === params?.threadID) { const prefixThisThreadNounWith = params?.prefixThisThreadNounWith === 'your' ? 'your' : 'this'; name = `${prefixThisThreadNounWith} ${noun}`; } else { name = `a ${noun}`; } } if (entity.possessive) { name = makePossessive({ str: name }); } return name; } function getNameForUserEntity( entity: UserEntity, ignoreViewer: ?boolean, ): string { const isViewer = entity.isViewer && !ignoreViewer; const entityWithIsViewerIgnored = { ...entity, isViewer }; const str = stringForUser(entityWithIsViewerIgnored); if (!entityWithIsViewerIgnored.possessive) { return str; } return makePossessive({ str, isViewer }); } function getNameForFarcasterUserEntity(entity: FarcasterUserEntity): string { return entity.farcasterUsername ?? ''; } type EntityTextToRawStringParams = { +threadID?: ?string, +ignoreViewer?: ?boolean, +prefixThisThreadNounWith?: ?('this' | 'your'), }; function entityTextToRawString( entityText: EntityText, params?: ?EntityTextToRawStringParams, ): string { // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return const textParts = entityText.map(entity => { if (typeof entity === 'string') { return entity; } else if (entity.type === 'thread') { return getNameForThreadEntity(entity, params); } else if (entity.type === 'color') { return entity.hex; } else if (entity.type === 'user') { return getNameForUserEntity(entity, params?.ignoreViewer); } else if (entity.type === 'farcaster_user') { return getNameForFarcasterUserEntity(entity); } else { invariant( false, `entityTextToRawString can't handle entity type ${entity.type}`, ); } }); return textParts.join(''); } type RenderFunctions = { +renderText: ({ +text: string }) => React.Node, +renderThread: ({ +id: string, +name: string }) => React.Node, +renderUser: ({ +userID: string, +usernameText: string }) => React.Node, +renderColor: ({ +hex: string }) => React.Node, +renderFarcasterUser: ({ +farcasterUsername: string }) => React.Node, }; function entityTextToReact( entityText: EntityText, threadID: string, renderFuncs: RenderFunctions, ): React.Node { const { renderText, renderThread, renderUser, renderColor, renderFarcasterUser, } = renderFuncs; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return return entityText.map((entity, i) => { const key = `text${i}`; if (typeof entity === 'string') { return ( {renderText({ text: entity })} ); } else if (entity.type === 'thread') { const { id } = entity; const name = getNameForThreadEntity(entity, { threadID }); if (id === threadID) { return name; } else { return ( {renderThread({ id, name })} ); } } else if (entity.type === 'color') { return ( {renderColor({ hex: entity.hex })} ); } else if (entity.type === 'user') { const userID = entity.id; const usernameText = getNameForUserEntity(entity); return ( {renderUser({ userID, usernameText })} ); } else if (entity.type === 'farcaster_user') { const name = getNameForFarcasterUserEntity(entity); return ( {renderFarcasterUser({ farcasterUsername: name })} ); } else { invariant( false, `entityTextToReact can't handle entity type ${entity.type}`, ); } }); } function pluralizeEntityText( nouns: $ReadOnlyArray, maxNumberOfNouns: number = 3, ): EntityText { return basePluralize( nouns, maxNumberOfNouns, (a: EntityText | string, b: ?EntityText | string) => b ? ET`${a}${b}` : ET`${a}`, ); } type TextEntity = { +type: 'text', +text: string }; type ShadowUserEntity = { +type: 'shadowUser', +username: string, +originalUsername: string, }; type EntityTextComponentAsObject = | UserEntity | ThreadEntity | ColorEntity | TextEntity | FarcasterUserEntity | ShadowUserEntity; function entityTextToObjects( entityText: EntityText, ): EntityTextComponentAsObject[] { const objs: EntityTextComponentAsObject[] = []; for (const entity of entityText) { if (typeof entity === 'string') { objs.push({ type: 'text', text: entity }); continue; } objs.push(entity); if ( entity.type === 'thread' && entity.display === 'uiName' && typeof entity.uiName !== 'string' ) { for (const innerEntity of entity.uiName) { if (typeof innerEntity === 'string' || innerEntity.type !== 'user') { continue; } const { username } = innerEntity; if (username) { objs.push({ type: 'shadowUser', originalUsername: username, username, }); } } } } return objs; } function entityTextFromObjects( objects: $ReadOnlyArray, ): EntityText { const shadowUserMap = new Map(); for (const obj of objects) { if (obj.type === 'shadowUser' && obj.username !== obj.originalUsername) { shadowUserMap.set(obj.originalUsername, obj.username); } } return objects .map(entity => { if (entity.type === 'text') { return entity.text; } else if (entity.type === 'shadowUser') { return null; } else if ( entity.type === 'thread' && entity.display === 'uiName' && typeof entity.uiName !== 'string' ) { const uiName: UserEntity[] = []; let changeOccurred = false; for (const innerEntity of entity.uiName) { if (typeof innerEntity === 'string' || innerEntity.type !== 'user') { uiName.push(innerEntity); continue; } const { username } = innerEntity; if (!username) { uiName.push(innerEntity); continue; } const ensName = shadowUserMap.get(username); if (!ensName) { uiName.push(innerEntity); continue; } changeOccurred = true; uiName.push({ ...innerEntity, username: ensName, }); } if (!changeOccurred) { return entity; } return { ...entity, uiName, }; } else { return entity; } }) .filter(Boolean); } export type UseResolvedEntityTextOptions = { +allAtOnce?: ?boolean, }; function useResolvedEntityText( entityText: ?EntityText, options?: ?UseResolvedEntityTextOptions, ): ?EntityText { const allObjects = React.useMemo( () => (entityText ? entityTextToObjects(entityText) : []), [entityText], ); const objectsWithENSNames = useENSNames(allObjects, options); const objectsWithFCNames = useFCNames(allObjects, options); return React.useMemo(() => { if (!entityText) { return entityText; } const mergedObjects = []; for (let i = 0; i < allObjects.length; i++) { const originalObject = allObjects[i]; const updatedObject = originalObject.fid ? objectsWithFCNames[i] : objectsWithENSNames[i]; mergedObjects.push(updatedObject); } return entityTextFromObjects(mergedObjects); }, [entityText, allObjects, objectsWithENSNames, objectsWithFCNames]); } function useEntityTextAsString( entityText: ?EntityText, params?: EntityTextToRawStringParams, ): ?string { const withENSNames = useResolvedEntityText(entityText); return React.useMemo(() => { if (!withENSNames) { return withENSNames; } return entityTextToRawString(withENSNames, params); }, [withENSNames, params]); } type Fetchers = { +getENSNames?: ?GetENSNames, +getFCNames?: ?GetFCNames, }; async function getEntityTextAsString( entityText: ?EntityText, fetchers?: Fetchers, params?: EntityTextToRawStringParams, ): Promise { if (!entityText) { return entityText; } const getENSNames = fetchers?.getENSNames; const getFCNames = fetchers?.getFCNames; if (!getENSNames && !getFCNames) { return entityTextToRawString(entityText, params); } const allObjects = entityTextToObjects(entityText); const objectsWithENSNamesPromise = (async () => { if (!getENSNames) { return allObjects; } return await getENSNames(allObjects); })(); const objectsWithFCNamesPromise = (async () => { if (!getFCNames) { return allObjects; } return await getFCNames(allObjects); })(); const [objectsWithENSNames, objectsWithFCNames] = await Promise.all([ objectsWithENSNamesPromise, objectsWithFCNamesPromise, ]); const mergedObjects = []; for (let i = 0; i < allObjects.length; i++) { const originalObject = allObjects[i]; const updatedObject = originalObject.fid ? objectsWithFCNames[i] : objectsWithENSNames[i]; mergedObjects.push(updatedObject); } const resolvedEntityText = entityTextFromObjects(mergedObjects); return entityTextToRawString(resolvedEntityText, params); } export { ET, entityTextToRawString, entityTextToReact, getNameForThreadEntity, pluralizeEntityText, useResolvedEntityText, useEntityTextAsString, getEntityTextAsString, }; diff --git a/native/avatars/thread-avatar.react.js b/native/avatars/thread-avatar.react.js index f173e9b9a..30b0c67da 100644 --- a/native/avatars/thread-avatar.react.js +++ b/native/avatars/thread-avatar.react.js @@ -1,51 +1,51 @@ // @flow import * as React from 'react'; import { useAvatarForThread } from 'lib/hooks/avatar-hooks.js'; import { useENSResolvedAvatar } from 'lib/shared/avatar-utils.js'; import { getSingleOtherUser } from 'lib/shared/thread-utils.js'; import type { AvatarSize } from 'lib/types/avatar-types.js'; import type { ThreadInfo, ResolvedThreadInfo, RawThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import Avatar from './avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { +threadInfo: RawThreadInfo | ThreadInfo | ResolvedThreadInfo, +size: AvatarSize, }; function ThreadAvatar(props: Props): React.Node { const { threadInfo, size } = props; const avatarInfo = useAvatarForThread(threadInfo); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); let displayUserIDForThread; - if (threadInfo.type === threadTypes.PRIVATE) { + if (threadInfo.type === threadTypes.GENESIS_PRIVATE) { displayUserIDForThread = viewerID; - } else if (threadInfo.type === threadTypes.PERSONAL) { + } else if (threadInfo.type === threadTypes.GENESIS_PERSONAL) { displayUserIDForThread = getSingleOtherUser(threadInfo, viewerID); } const displayUser = useSelector(state => displayUserIDForThread ? state.userStore.userInfos[displayUserIDForThread] : null, ); const resolvedThreadAvatar = useENSResolvedAvatar(avatarInfo, displayUser); return ; } export default ThreadAvatar; diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index 683360399..3c5521d8f 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,500 +1,500 @@ // @flow import IonIcon from '@expo/vector-icons/Ionicons.js'; import type { BottomTabNavigationEventMap, BottomTabOptions, StackNavigationEventMap, StackNavigationState, StackOptions, TabNavigationState, } from '@react-navigation/core'; import invariant from 'invariant'; import * as React from 'react'; import { BackHandler, FlatList, Platform, TextInput, TouchableWithoutFeedback, View, } from 'react-native'; import { FloatingAction } from 'react-native-floating-action'; import { useSharedValue } from 'react-native-reanimated'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useThreadListSearch } from 'lib/hooks/thread-search-hooks.js'; import { type ChatThreadItem, useFlattenedChatListData, } from 'lib/selectors/chat-selectors.js'; import { createPendingThread, getThreadListSearchResults, } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { UserInfo } from 'lib/types/user-types.js'; import { ChatThreadListItem } from './chat-thread-list-item.react.js'; import ChatThreadListSearch from './chat-thread-list-search.react.js'; import { getItemLayout, keyExtractor } from './chat-thread-list-utils.js'; import type { ChatNavigationProp, ChatTopTabsNavigationProp, } from './chat.react.js'; import { useNavigateToThread } from './message-list-types.js'; import { BackgroundChatThreadListRouteName, HomeChatThreadListRouteName, type NavigationRoute, type ScreenParamList, SidebarListModalRouteName, } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { indicatorStyleSelector, useStyles } from '../themes/colors.js'; import type { ScrollEvent } from '../types/react-native.js'; const floatingActions = [ { text: 'Compose', icon: , name: 'compose', position: 1, }, ]; export type Item = | ChatThreadItem | { +type: 'search', +searchText: string } | { +type: 'empty', +emptyItem: React.ComponentType<{}> }; type BaseProps = { +navigation: | ChatTopTabsNavigationProp<'HomeChatThreadList'> | ChatTopTabsNavigationProp<'BackgroundChatThreadList'>, +route: | NavigationRoute<'HomeChatThreadList'> | NavigationRoute<'BackgroundChatThreadList'>, +filterThreads: (threadItem: ThreadInfo) => boolean, +emptyItem?: React.ComponentType<{}>, }; export type SearchStatus = 'inactive' | 'activating' | 'active'; function ChatThreadList(props: BaseProps): React.Node { const boundChatListData = useFlattenedChatListData(); const loggedInUserInfo = useLoggedInUserInfo(); const styles = useStyles(unboundStyles); const indicatorStyle = useSelector(indicatorStyleSelector); const navigateToThread = useNavigateToThread(); const { navigation, route, filterThreads, emptyItem } = props; const [searchText, setSearchText] = React.useState(''); const [searchStatus, setSearchStatus] = React.useState('inactive'); const { threadSearchResults, usersSearchResults } = useThreadListSearch( searchText, loggedInUserInfo?.id, ); const [openedSwipeableID, setOpenedSwipeableID] = React.useState(''); const [numItemsToDisplay, setNumItemsToDisplay] = React.useState(25); const onChangeSearchText = React.useCallback((updatedSearchText: string) => { setSearchText(updatedSearchText); setNumItemsToDisplay(25); }, []); const scrollPos = React.useRef(0); const flatListRef = React.useRef>(); const onScroll = React.useCallback( (event: ScrollEvent) => { const oldScrollPos = scrollPos.current; scrollPos.current = event.nativeEvent.contentOffset.y; if (scrollPos.current !== 0 || oldScrollPos === 0) { return; } if (searchStatus === 'activating') { setSearchStatus('active'); } }, [searchStatus], ); const onSwipeableWillOpen = React.useCallback( (threadInfo: ThreadInfo) => setOpenedSwipeableID(threadInfo.id), [], ); const composeThread = React.useCallback(() => { if (!loggedInUserInfo) { return; } const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, - threadType: threadTypes.PRIVATE, + threadType: threadTypes.GENESIS_PRIVATE, members: [loggedInUserInfo], }); navigateToThread({ threadInfo, searching: true }); }, [loggedInUserInfo, navigateToThread]); const onSearchFocus = React.useCallback(() => { if (searchStatus !== 'inactive') { return; } if (scrollPos.current === 0) { setSearchStatus('active'); } else { setSearchStatus('activating'); } }, [searchStatus]); const clearSearch = React.useCallback(() => { if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: false }); } setSearchStatus('inactive'); }, []); const onSearchBlur = React.useCallback(() => { if (searchStatus !== 'active') { return; } clearSearch(); }, [clearSearch, searchStatus]); const onSearchCancel = React.useCallback(() => { onChangeSearchText(''); clearSearch(); }, [clearSearch, onChangeSearchText]); const searchInputRef = React.useRef>(); const onPressItem = React.useCallback( (threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo) => { onChangeSearchText(''); if (searchInputRef.current) { searchInputRef.current.blur(); } navigateToThread({ threadInfo, pendingPersonalThreadUserInfo }); }, [navigateToThread, onChangeSearchText], ); const onPressSeeMoreSidebars = React.useCallback( (threadInfo: ThreadInfo) => { onChangeSearchText(''); if (searchInputRef.current) { searchInputRef.current.blur(); } navigation.navigate<'SidebarListModal'>({ name: SidebarListModalRouteName, params: { threadInfo }, }); }, [navigation, onChangeSearchText], ); const hardwareBack = React.useCallback(() => { if (!navigation.isFocused()) { return false; } const isActiveOrActivating = searchStatus === 'active' || searchStatus === 'activating'; if (!isActiveOrActivating) { return false; } onSearchCancel(); return true; }, [navigation, onSearchCancel, searchStatus]); const cancelButtonExpansion = useSharedValue(0); const searchItem = React.useMemo( () => ( ), [ onChangeSearchText, onSearchBlur, onSearchCancel, onSearchFocus, searchStatus, searchText, styles.searchContainer, cancelButtonExpansion, ], ); const renderItem = React.useCallback( (row: { item: Item, ... }) => { const item = row.item; if (item.type === 'search') { return searchItem; } if (item.type === 'empty') { const EmptyItem = item.emptyItem; return ; } return ( ); }, [ onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, openedSwipeableID, searchItem, ], ); const listData: $ReadOnlyArray = React.useMemo(() => { const chatThreadItems = getThreadListSearchResults( boundChatListData, searchText, filterThreads, threadSearchResults, usersSearchResults, loggedInUserInfo, ); const chatItems: Item[] = [...chatThreadItems]; if (emptyItem && chatItems.length === 0) { chatItems.push({ type: 'empty', emptyItem }); } if (searchStatus === 'inactive' || searchStatus === 'activating') { chatItems.unshift({ type: 'search', searchText }); } return chatItems; }, [ boundChatListData, emptyItem, filterThreads, loggedInUserInfo, searchStatus, searchText, threadSearchResults, usersSearchResults, ]); const partialListData: $ReadOnlyArray = React.useMemo( () => listData.slice(0, numItemsToDisplay), [listData, numItemsToDisplay], ); const onEndReached = React.useCallback(() => { if (partialListData.length === listData.length) { return; } setNumItemsToDisplay(prevNumItems => prevNumItems + 25); }, [listData.length, partialListData.length]); const floatingAction = React.useMemo(() => { if (Platform.OS !== 'android') { return null; } return ( ); }, [composeThread]); const fixedSearch = React.useMemo(() => { if (searchStatus !== 'active') { return null; } return ( ); }, [ onChangeSearchText, onSearchBlur, onSearchCancel, searchStatus, searchText, styles.searchContainer, cancelButtonExpansion, ]); const scrollEnabled = searchStatus === 'inactive' || searchStatus === 'active'; // viewerID is in extraData since it's used by MessagePreview // within ChatThreadListItem const viewerID = loggedInUserInfo?.id; const extraData = `${viewerID || ''} ${openedSwipeableID}`; const chatThreadList = React.useMemo( () => ( {fixedSearch} {floatingAction} ), [ extraData, fixedSearch, floatingAction, indicatorStyle, onEndReached, onScroll, partialListData, renderItem, scrollEnabled, styles.container, styles.flatList, ], ); const onTabPress = React.useCallback(() => { if (!navigation.isFocused()) { return; } if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } else if (route.name === BackgroundChatThreadListRouteName) { navigation.navigate({ name: HomeChatThreadListRouteName }); } }, [navigation, route.name]); React.useEffect(() => { const clearNavigationBlurListener = navigation.addListener('blur', () => { setNumItemsToDisplay(25); }); return () => { // `.addListener` returns function that can be called to unsubscribe. // https://reactnavigation.org/docs/navigation-events/#navigationaddlistener clearNavigationBlurListener(); }; }, [navigation]); React.useEffect(() => { const chatNavigation = navigation.getParent< ScreenParamList, 'ChatThreadList', StackNavigationState, StackOptions, StackNavigationEventMap, ChatNavigationProp<'ChatThreadList'>, >(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation = chatNavigation.getParent< ScreenParamList, 'Chat', TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, TabNavigationProp<'Chat'>, >(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', onTabPress); return () => { tabNavigation.removeListener('tabPress', onTabPress); }; }, [navigation, onTabPress]); React.useEffect(() => { BackHandler.addEventListener('hardwareBackPress', hardwareBack); return () => { BackHandler.removeEventListener('hardwareBackPress', hardwareBack); }; }, [hardwareBack]); React.useEffect(() => { if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: false }); } }, [searchText]); const isSearchActivating = searchStatus === 'activating'; React.useEffect(() => { if (isSearchActivating && scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } }, [isSearchActivating]); return chatThreadList; } const unboundStyles = { icon: { fontSize: 28, }, container: { flex: 1, }, searchContainer: { backgroundColor: 'listBackground', display: 'flex', justifyContent: 'center', flexDirection: 'row', }, flatList: { flex: 1, backgroundColor: 'listBackground', }, }; export default ChatThreadList; diff --git a/native/chat/compose-thread-button.react.js b/native/chat/compose-thread-button.react.js index bd0d4696f..3cce25d8a 100644 --- a/native/chat/compose-thread-button.react.js +++ b/native/chat/compose-thread-button.react.js @@ -1,61 +1,61 @@ // @flow import * as React from 'react'; import { StyleSheet } from 'react-native'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { createPendingThread } from 'lib/shared/thread-utils.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { ChatNavigationProp } from './chat.react.js'; import Button from '../components/button.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { MessageListRouteName } from '../navigation/route-names.js'; import { useColors } from '../themes/colors.js'; type Props = { +navigate: $PropertyType, 'navigate'>, }; function ComposeThreadButton(props: Props) { const { navigate } = props; const loggedInUserInfo = useLoggedInUserInfo(); const onPress = React.useCallback(() => { if (!loggedInUserInfo) { return; } navigate<'MessageList'>({ name: MessageListRouteName, params: { threadInfo: createPendingThread({ viewerID: loggedInUserInfo.id, - threadType: threadTypes.PRIVATE, + threadType: threadTypes.GENESIS_PRIVATE, members: [loggedInUserInfo], }), searching: true, }, }); }, [navigate, loggedInUserInfo]); const { listForegroundSecondaryLabel } = useColors(); return ( ); } const styles = StyleSheet.create({ composeButton: { marginRight: 16, }, }); const MemoizedComposeThreadButton: React.ComponentType = React.memo(ComposeThreadButton); export default MemoizedComposeThreadButton; diff --git a/native/chat/message-list.react.js b/native/chat/message-list.react.js index 4f2ee3b13..49c1a3cec 100644 --- a/native/chat/message-list.react.js +++ b/native/chat/message-list.react.js @@ -1,373 +1,373 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import * as React from 'react'; import { TouchableWithoutFeedback, View } from 'react-native'; import { createSelector } from 'reselect'; import { fetchMessagesBeforeCursorActionTypes, type FetchMessagesBeforeCursorInput, fetchMostRecentMessagesActionTypes, type FetchMostRecentMessagesInput, useFetchMessagesBeforeCursor, useFetchMostRecentMessages, } from 'lib/actions/message-actions.js'; import { useOldestMessageServerID } from 'lib/hooks/message-hooks.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { messageKey } from 'lib/shared/message-utils.js'; import { useWatchThread } from 'lib/shared/watch-thread-utils.js'; import type { FetchMessageInfosPayload } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import ChatList from './chat-list.react.js'; import type { ChatNavigationProp } from './chat.react.js'; import Message from './message.react.js'; import RelationshipPrompt from './relationship-prompt.react.js'; import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; import { KeyboardContext, type KeyboardState, } from '../keyboard/keyboard-state.js'; import { defaultStackScreenOptions } from '../navigation/options.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type IndicatorStyle, useIndicatorStyle, useStyles, } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight, ChatMessageItemWithHeight, } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; import type { ViewableItemsChange } from '../types/react-native.js'; const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, header: { height: 12, }, listLoadingIndicator: { flex: 1, }, }; type BaseProps = { +threadInfo: ThreadInfo, +messageListData: $ReadOnlyArray, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; type Props = { ...BaseProps, +startReached: boolean, +styles: $ReadOnly, +indicatorStyle: IndicatorStyle, +dispatchActionPromise: DispatchActionPromise, +fetchMessagesBeforeCursor: ( input: FetchMessagesBeforeCursorInput, ) => Promise, +fetchMostRecentMessages: ( input: FetchMostRecentMessagesInput, ) => Promise, +overlayContext: ?OverlayContextType, +keyboardState: ?KeyboardState, +oldestMessageServerID: ?string, }; type State = { +focusedMessageKey: ?string, +messageListVerticalBounds: ?VerticalBounds, +loadingFromScroll: boolean, }; type PropsAndState = { ...Props, ...State, }; type FlatListExtraData = { messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, }; class MessageList extends React.PureComponent { state: State = { focusedMessageKey: null, messageListVerticalBounds: null, loadingFromScroll: false, }; flatListContainer: ?React.ElementRef; flatListExtraDataSelector: PropsAndState => FlatListExtraData = createSelector( (propsAndState: PropsAndState) => propsAndState.messageListVerticalBounds, (propsAndState: PropsAndState) => propsAndState.focusedMessageKey, (propsAndState: PropsAndState) => propsAndState.navigation, (propsAndState: PropsAndState) => propsAndState.route, ( messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, ) => ({ messageListVerticalBounds, focusedMessageKey, navigation, route, }), ); get flatListExtraData(): FlatListExtraData { return this.flatListExtraDataSelector({ ...this.props, ...this.state }); } static getOverlayContext(props: Props): OverlayContextType { const { overlayContext } = props; invariant(overlayContext, 'MessageList should have OverlayContext'); return overlayContext; } static scrollDisabled(props: Props): boolean { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus !== 'closed'; } static modalOpen(props: Props): boolean { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus === 'open'; } componentDidUpdate(prevProps: Props) { const modalIsOpen = MessageList.modalOpen(this.props); const modalWasOpen = MessageList.modalOpen(prevProps); if (!modalIsOpen && modalWasOpen) { this.setState({ focusedMessageKey: null }); } if (defaultStackScreenOptions.gestureEnabled) { const scrollIsDisabled = MessageList.scrollDisabled(this.props); const scrollWasDisabled = MessageList.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } } dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; renderItem = (row: { item: ChatMessageItemWithHeight, ... }): React.Node => { if (row.item.itemType === 'loader') { return ( ); } const messageInfoItem: ChatMessageInfoItemWithHeight = row.item; const { messageListVerticalBounds, focusedMessageKey, navigation, route } = this.flatListExtraData; const focused = messageKey(messageInfoItem.messageInfo) === focusedMessageKey; return ( ); }; toggleMessageFocus = (inMessageKey: string) => { if (this.state.focusedMessageKey === inMessageKey) { this.setState({ focusedMessageKey: null }); } else { this.setState({ focusedMessageKey: inMessageKey }); } }; // Actually header, it's just that our FlatList is inverted ListFooterComponent = (): React.Node => ( ); render(): React.Node { const { messageListData, startReached } = this.props; const footer = startReached ? this.ListFooterComponent : undefined; let relationshipPrompt = null; - if (this.props.threadInfo.type === threadTypes.PERSONAL) { + if (this.props.threadInfo.type === threadTypes.GENESIS_PERSONAL) { relationshipPrompt = ( ); } return ( {relationshipPrompt} ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ messageListVerticalBounds: { height, y: pageY } }); }); }; onViewableItemsChanged = (info: ViewableItemsChange) => { if (this.state.focusedMessageKey) { let focusedMessageVisible = false; for (const token of info.viewableItems) { if ( token.item.itemType === 'message' && messageKey(token.item.messageInfo) === this.state.focusedMessageKey ) { focusedMessageVisible = true; break; } } if (!focusedMessageVisible) { this.setState({ focusedMessageKey: null }); } } const loader = _find({ key: 'loader' })(info.viewableItems); if (!loader || this.state.loadingFromScroll) { return; } this.setState({ loadingFromScroll: true }); const { oldestMessageServerID } = this.props; const threadID = this.props.threadInfo.id; void (async () => { try { if (oldestMessageServerID) { await this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor({ threadID, beforeMessageID: oldestMessageServerID, }), ); } else { await this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, this.props.fetchMostRecentMessages({ threadID }), ); } } finally { this.setState({ loadingFromScroll: false }); } })(); }; } registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); const ConnectedMessageList: React.ComponentType = React.memo(function ConnectedMessageList(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const threadID = props.threadInfo.id; const startReached = useSelector( state => !!( state.messageStore.threads[threadID] && state.messageStore.threads[threadID].startReached ), ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useFetchMessagesBeforeCursor(); const callFetchMostRecentMessages = useFetchMostRecentMessages(); const oldestMessageServerID = useOldestMessageServerID(threadID); useWatchThread(props.threadInfo); return ( ); }); export default ConnectedMessageList; diff --git a/native/chat/settings/thread-settings.react.js b/native/chat/settings/thread-settings.react.js index d954c6be8..136c14dda 100644 --- a/native/chat/settings/thread-settings.react.js +++ b/native/chat/settings/thread-settings.react.js @@ -1,1364 +1,1365 @@ // @flow import type { BottomTabNavigationEventMap, BottomTabOptions, TabNavigationState, } from '@react-navigation/core'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, View } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { createSelector } from 'reselect'; import tinycolor from 'tinycolor2'; import { fetchPrimaryInviteLinkActionTypes, useFetchPrimaryInviteLinks, } from 'lib/actions/link-actions.js'; import { changeThreadMemberRolesActionTypes, changeThreadSettingsActionTypes, leaveThreadActionTypes, removeUsersFromThreadActionTypes, } from 'lib/actions/thread-actions.js'; import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react.js'; import { useAddUsersPermissions } from 'lib/permissions/add-users-permissions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { childThreadInfos, threadInfoSelector, } from 'lib/selectors/thread-selectors.js'; import { getAvailableRelationshipButtons } from 'lib/shared/relationship-utils.js'; import { getSingleOtherUser, threadIsChannel, useThreadHasPermission, useIsThreadInChatList, viewerIsMember, } from 'lib/shared/thread-utils.js'; import threadWatcher from 'lib/shared/thread-watcher.js'; import type { RelativeMemberInfo, ResolvedThreadInfo, ThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { RelationshipButton } from 'lib/types/relationship-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { UserInfos } from 'lib/types/user-types.js'; import { useResolvedOptionalThreadInfo, useResolvedOptionalThreadInfos, useResolvedThreadInfo, } from 'lib/utils/entity-helpers.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import ThreadSettingsAvatar from './thread-settings-avatar.react.js'; import type { CategoryType } from './thread-settings-category.react.js'; import { ThreadSettingsCategoryActionHeader, ThreadSettingsCategoryFooter, ThreadSettingsCategoryHeader, } from './thread-settings-category.react.js'; import ThreadSettingsChildThread from './thread-settings-child-thread.react.js'; import ThreadSettingsColor from './thread-settings-color.react.js'; import ThreadSettingsDeleteThread from './thread-settings-delete-thread.react.js'; import ThreadSettingsDescription from './thread-settings-description.react.js'; import ThreadSettingsEditRelationship from './thread-settings-edit-relationship.react.js'; import ThreadSettingsHomeNotifs from './thread-settings-home-notifs.react.js'; import ThreadSettingsLeaveThread from './thread-settings-leave-thread.react.js'; import { ThreadSettingsAddMember, ThreadSettingsAddSubchannel, ThreadSettingsSeeMore, } from './thread-settings-list-action.react.js'; import ThreadSettingsMediaGallery from './thread-settings-media-gallery.react.js'; import ThreadSettingsMember from './thread-settings-member.react.js'; import ThreadSettingsName from './thread-settings-name.react.js'; import ThreadSettingsParent from './thread-settings-parent.react.js'; import ThreadSettingsPromoteSidebar from './thread-settings-promote-sidebar.react.js'; import ThreadSettingsPushNotifs from './thread-settings-push-notifs.react.js'; import ThreadSettingsVisibility from './thread-settings-visibility.react.js'; import ThreadAncestors from '../../components/thread-ancestors.react.js'; import { KeyboardContext, type KeyboardState, } from '../../keyboard/keyboard-state.js'; import { defaultStackScreenOptions } from '../../navigation/options.js'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context.js'; import { InviteLinkNavigatorRouteName, ViewInviteLinksRouteName, ManagePublicLinkRouteName, AddUsersModalRouteName, } from '../../navigation/route-names.js'; import { ComposeSubchannelModalRouteName, FullScreenThreadMediaGalleryRouteName, type NavigationRoute, type ScreenParamList, } from '../../navigation/route-names.js'; import type { TabNavigationProp } from '../../navigation/tab-navigator.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import type { AppState } from '../../redux/state-types.js'; import { type IndicatorStyle, useIndicatorStyle, useStyles, } from '../../themes/colors.js'; import type { VerticalBounds } from '../../types/layout-types.js'; import type { ViewStyle } from '../../types/styles.js'; import type { ChatNavigationProp } from '../chat.react.js'; const itemPageLength = 5; export type ThreadSettingsParams = { +threadInfo: ThreadInfo, }; export type ThreadSettingsNavigate = $PropertyType< ChatNavigationProp<'ThreadSettings'>, 'navigate', >; type ChatSettingsItem = | { +itemType: 'header', +key: string, +title: string, +categoryType: CategoryType, } | { +itemType: 'actionHeader', +key: string, +title: string, +actionText: string, +onPress: () => void, } | { +itemType: 'footer', +key: string, +categoryType: CategoryType, } | { +itemType: 'avatar', +key: string, +threadInfo: ResolvedThreadInfo, +canChangeSettings: boolean, } | { +itemType: 'name', +key: string, +threadInfo: ResolvedThreadInfo, +nameEditValue: ?string, +canChangeSettings: boolean, } | { +itemType: 'color', +key: string, +threadInfo: ResolvedThreadInfo, +colorEditValue: string, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, } | { +itemType: 'description', +key: string, +threadInfo: ResolvedThreadInfo, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +canChangeSettings: boolean, } | { +itemType: 'parent', +key: string, +threadInfo: ResolvedThreadInfo, +parentThreadInfo: ?ResolvedThreadInfo, } | { +itemType: 'visibility', +key: string, +threadInfo: ResolvedThreadInfo, } | { +itemType: 'pushNotifs', +key: string, +threadInfo: ResolvedThreadInfo, } | { +itemType: 'homeNotifs', +key: string, +threadInfo: ResolvedThreadInfo, } | { +itemType: 'seeMore', +key: string, +onPress: () => void, } | { +itemType: 'childThread', +key: string, +threadInfo: ResolvedThreadInfo, +firstListItem: boolean, +lastListItem: boolean, } | { +itemType: 'addSubchannel', +key: string, } | { +itemType: 'member', +key: string, +memberInfo: RelativeMemberInfo, +threadInfo: ResolvedThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, } | { +itemType: 'addMember', +key: string, } | { +itemType: 'mediaGallery', +key: string, +threadInfo: ThreadInfo, +limit: number, +verticalBounds: ?VerticalBounds, } | { +itemType: 'promoteSidebar' | 'leaveThread' | 'deleteThread', +key: string, +threadInfo: ResolvedThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, } | { +itemType: 'editRelationship', +key: string, +threadInfo: ResolvedThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, +relationshipButton: RelationshipButton, }; const unboundStyles = { container: { backgroundColor: 'panelBackground', flex: 1, }, flatList: { paddingVertical: 16, }, nonTopButton: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 14 : 12, }, }; type BaseProps = { +navigation: ChatNavigationProp<'ThreadSettings'>, +route: NavigationRoute<'ThreadSettings'>, }; type Props = { ...BaseProps, // Redux state +userInfos: UserInfos, +viewerID: ?string, +threadInfo: ResolvedThreadInfo, +parentThreadInfo: ?ResolvedThreadInfo, +childThreadInfos: ?$ReadOnlyArray, +somethingIsSaving: boolean, +styles: $ReadOnly, +indicatorStyle: IndicatorStyle, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, +canPromoteSidebar: boolean, +canEditThreadAvatar: boolean, +canEditThreadName: boolean, +canEditThreadDescription: boolean, +canEditThreadColor: boolean, +canCreateSubchannels: boolean, +canAddMembers: boolean, +canLeaveThread: boolean, +canDeleteThread: boolean, +canManageInviteLinks: boolean, +inviteLinkExists: boolean, }; type State = { +numMembersShowing: number, +numSubchannelsShowing: number, +numSidebarsShowing: number, +nameEditValue: ?string, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +colorEditValue: string, +verticalBounds: ?VerticalBounds, }; type PropsAndState = { ...Props, ...State }; class ThreadSettings extends React.PureComponent { flatListContainer: ?React.ElementRef; constructor(props: Props) { super(props); this.state = { numMembersShowing: itemPageLength, numSubchannelsShowing: itemPageLength, numSidebarsShowing: itemPageLength, nameEditValue: null, descriptionEditValue: null, descriptionTextHeight: null, colorEditValue: props.threadInfo.color, verticalBounds: null, }; } static scrollDisabled(props: Props): boolean { const { overlayContext } = props; invariant(overlayContext, 'ThreadSettings should have OverlayContext'); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidUpdate(prevProps: Props) { const prevThreadInfo = prevProps.threadInfo; const newThreadInfo = this.props.threadInfo; if ( !tinycolor.equals(newThreadInfo.color, prevThreadInfo.color) && tinycolor.equals(this.state.colorEditValue, prevThreadInfo.color) ) { this.setState({ colorEditValue: newThreadInfo.color }); } if (defaultStackScreenOptions.gestureEnabled) { const scrollIsDisabled = ThreadSettings.scrollDisabled(this.props); const scrollWasDisabled = ThreadSettings.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } } threadBasicsListDataSelector: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.nameEditValue, (propsAndState: PropsAndState) => propsAndState.colorEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionTextHeight, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, (propsAndState: PropsAndState) => propsAndState.canEditThreadAvatar, (propsAndState: PropsAndState) => propsAndState.canEditThreadName, (propsAndState: PropsAndState) => propsAndState.canEditThreadDescription, (propsAndState: PropsAndState) => propsAndState.canEditThreadColor, ( threadInfo: ResolvedThreadInfo, parentThreadInfo: ?ResolvedThreadInfo, nameEditValue: ?string, colorEditValue: string, descriptionEditValue: ?string, descriptionTextHeight: ?number, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, canEditThreadAvatar: boolean, canEditThreadName: boolean, canEditThreadDescription: boolean, canEditThreadColor: boolean, ) => { const canChangeAvatar = canEditThreadAvatar && canStartEditing; const canChangeName = canEditThreadName && canStartEditing; const canChangeDescription = canEditThreadDescription && canStartEditing; const canChangeColor = canEditThreadColor && canStartEditing; const listData: ChatSettingsItem[] = []; listData.push({ itemType: 'header', key: 'avatarHeader', title: 'Channel Avatar', categoryType: 'unpadded', }); listData.push({ itemType: 'avatar', key: 'avatar', threadInfo, canChangeSettings: canChangeAvatar, }); listData.push({ itemType: 'footer', key: 'avatarFooter', categoryType: 'outline', }); listData.push({ itemType: 'header', key: 'basicsHeader', title: 'Basics', categoryType: 'full', }); listData.push({ itemType: 'name', key: 'name', threadInfo, nameEditValue, canChangeSettings: canChangeName, }); listData.push({ itemType: 'color', key: 'color', threadInfo, colorEditValue, canChangeSettings: canChangeColor, navigate, threadSettingsRouteKey: routeKey, }); listData.push({ itemType: 'footer', key: 'basicsFooter', categoryType: 'full', }); if ( (descriptionEditValue !== null && descriptionEditValue !== undefined) || threadInfo.description || canEditThreadDescription ) { listData.push({ itemType: 'description', key: 'description', threadInfo, descriptionEditValue, descriptionTextHeight, canChangeSettings: canChangeDescription, }); } const isMember = viewerIsMember(threadInfo); if (isMember) { listData.push({ itemType: 'header', key: 'subscriptionHeader', title: 'Subscription', categoryType: 'full', }); listData.push({ itemType: 'pushNotifs', key: 'pushNotifs', threadInfo, }); if (threadInfo.type !== threadTypes.SIDEBAR) { listData.push({ itemType: 'homeNotifs', key: 'homeNotifs', threadInfo, }); } listData.push({ itemType: 'footer', key: 'subscriptionFooter', categoryType: 'full', }); } listData.push({ itemType: 'header', key: 'privacyHeader', title: 'Privacy', categoryType: 'full', }); listData.push({ itemType: 'visibility', key: 'visibility', threadInfo, }); listData.push({ itemType: 'parent', key: 'parent', threadInfo, parentThreadInfo, }); listData.push({ itemType: 'footer', key: 'privacyFooter', categoryType: 'full', }); return listData; }, ); subchannelsListDataSelector: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSubchannelsShowing, (propsAndState: PropsAndState) => propsAndState.canCreateSubchannels, ( threadInfo: ResolvedThreadInfo, navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSubchannelsShowing: number, canCreateSubchannels: boolean, ) => { const listData: ChatSettingsItem[] = []; const subchannels = childThreads?.filter(threadIsChannel) ?? []; if (subchannels.length === 0 && !canCreateSubchannels) { return listData; } listData.push({ itemType: 'header', key: 'subchannelHeader', title: 'Subchannels', categoryType: 'unpadded', }); if (canCreateSubchannels) { listData.push({ itemType: 'addSubchannel', key: 'addSubchannel', }); } const numItems = Math.min(numSubchannelsShowing, subchannels.length); for (let i = 0; i < numItems; i++) { const subchannelInfo = subchannels[i]; listData.push({ itemType: 'childThread', key: `childThread${subchannelInfo.id}`, threadInfo: subchannelInfo, firstListItem: i === 0 && !canCreateSubchannels, lastListItem: i === numItems - 1 && numItems === subchannels.length, }); } if (numItems < subchannels.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSubchannels', onPress: this.onPressSeeMoreSubchannels, }); } listData.push({ itemType: 'footer', key: 'subchannelFooter', categoryType: 'unpadded', }); return listData; }, ); sidebarsListDataSelector: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSidebarsShowing, ( navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSidebarsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const sidebars = childThreads?.filter( childThreadInfo => childThreadInfo.type === threadTypes.SIDEBAR, ) ?? []; if (sidebars.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'sidebarHeader', title: 'Threads', categoryType: 'unpadded', }); const numItems = Math.min(numSidebarsShowing, sidebars.length); for (let i = 0; i < numItems; i++) { const sidebarInfo = sidebars[i]; listData.push({ itemType: 'childThread', key: `childThread${sidebarInfo.id}`, threadInfo: sidebarInfo, firstListItem: i === 0, lastListItem: i === numItems - 1 && numItems === sidebars.length, }); } if (numItems < sidebars.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSidebars', onPress: this.onPressSeeMoreSidebars, }); } listData.push({ itemType: 'footer', key: 'sidebarFooter', categoryType: 'unpadded', }); return listData; }, ); threadMembersListDataSelector: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, (propsAndState: PropsAndState) => propsAndState.numMembersShowing, (propsAndState: PropsAndState) => propsAndState.verticalBounds, (propsAndState: PropsAndState) => propsAndState.canAddMembers, ( threadInfo: ResolvedThreadInfo, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, numMembersShowing: number, verticalBounds: ?VerticalBounds, canAddMembers: boolean, ) => { const listData: ChatSettingsItem[] = []; if (threadInfo.members.length === 0 && !canAddMembers) { return listData; } listData.push({ itemType: 'header', key: 'memberHeader', title: 'Members', categoryType: 'unpadded', }); if (canAddMembers) { listData.push({ itemType: 'addMember', key: 'addMember', }); } const numItems = Math.min(numMembersShowing, threadInfo.members.length); for (let i = 0; i < numItems; i++) { const memberInfo = threadInfo.members[i]; listData.push({ itemType: 'member', key: `member${memberInfo.id}`, memberInfo, threadInfo, canEdit: canStartEditing, navigate, firstListItem: i === 0 && !canAddMembers, lastListItem: i === numItems - 1 && numItems === threadInfo.members.length, verticalBounds, threadSettingsRouteKey: routeKey, }); } if (numItems < threadInfo.members.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreMembers', onPress: this.onPressSeeMoreMembers, }); } listData.push({ itemType: 'footer', key: 'memberFooter', categoryType: 'unpadded', }); return listData; }, ); mediaGalleryListDataSelector: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.verticalBounds, (threadInfo: ThreadInfo, verticalBounds: ?VerticalBounds) => { const listData: ChatSettingsItem[] = []; const limit = 6; listData.push({ itemType: 'actionHeader', key: 'mediaGalleryHeader', title: 'Media Gallery', actionText: 'See more', onPress: this.onPressSeeMoreMediaGallery, }); listData.push({ itemType: 'mediaGallery', key: 'mediaGallery', threadInfo, limit, verticalBounds, }); listData.push({ itemType: 'footer', key: 'mediaGalleryFooter', categoryType: 'outline', }); return listData; }, ); actionsListDataSelector: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.styles, (propsAndState: PropsAndState) => propsAndState.userInfos, (propsAndState: PropsAndState) => propsAndState.viewerID, (propsAndState: PropsAndState) => propsAndState.canLeaveThread, (propsAndState: PropsAndState) => propsAndState.canDeleteThread, ( threadInfo: ResolvedThreadInfo, parentThreadInfo: ?ResolvedThreadInfo, navigate: ThreadSettingsNavigate, styles: $ReadOnly, userInfos: UserInfos, viewerID: ?string, canLeaveThread: boolean, canDeleteThread: boolean, ) => { const buttons = []; if (this.props.canPromoteSidebar) { buttons.push({ itemType: 'promoteSidebar', key: 'promoteSidebar', threadInfo, navigate, }); } if (viewerIsMember(threadInfo) && canLeaveThread) { buttons.push({ itemType: 'leaveThread', key: 'leaveThread', threadInfo, navigate, }); } if (canDeleteThread) { buttons.push({ itemType: 'deleteThread', key: 'deleteThread', threadInfo, navigate, }); } - const threadIsPersonal = threadInfo.type === threadTypes.PERSONAL; + const threadIsPersonal = + threadInfo.type === threadTypes.GENESIS_PERSONAL; if (threadIsPersonal && viewerID) { const otherMemberID = getSingleOtherUser(threadInfo, viewerID); if (otherMemberID) { const otherUserInfo = userInfos[otherMemberID]; const availableRelationshipActions = getAvailableRelationshipButtons(otherUserInfo); for (const action of availableRelationshipActions) { buttons.push({ itemType: 'editRelationship', key: action, threadInfo, navigate, relationshipButton: action, }); } } } const listData: ChatSettingsItem[] = []; if (buttons.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'actionsHeader', title: 'Actions', categoryType: 'unpadded', }); for (let i = 0; i < buttons.length; i++) { // Necessary for Flow... if (buttons[i].itemType === 'editRelationship') { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } else { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } } listData.push({ itemType: 'footer', key: 'actionsFooter', categoryType: 'unpadded', }); return listData; }, ); listDataSelector: PropsAndState => $ReadOnlyArray = createSelector( this.threadBasicsListDataSelector, this.subchannelsListDataSelector, this.sidebarsListDataSelector, this.threadMembersListDataSelector, this.mediaGalleryListDataSelector, this.actionsListDataSelector, ( threadBasicsListData: $ReadOnlyArray, subchannelsListData: $ReadOnlyArray, sidebarsListData: $ReadOnlyArray, threadMembersListData: $ReadOnlyArray, mediaGalleryListData: $ReadOnlyArray, actionsListData: $ReadOnlyArray, ) => [ ...threadBasicsListData, ...subchannelsListData, ...sidebarsListData, ...threadMembersListData, ...mediaGalleryListData, ...actionsListData, ], ); get listData(): $ReadOnlyArray { return this.listDataSelector({ ...this.props, ...this.state }); } render(): React.Node { return ( ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ verticalBounds: { height, y: pageY } }); }); }; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return renderItem = (row: { +item: ChatSettingsItem, ... }): React.Node => { const item = row.item; if (item.itemType === 'header') { return ( ); } else if (item.itemType === 'actionHeader') { return ( ); } else if (item.itemType === 'footer') { return ; } else if (item.itemType === 'avatar') { return ( ); } else if (item.itemType === 'name') { return ( ); } else if (item.itemType === 'color') { return ( ); } else if (item.itemType === 'description') { return ( ); } else if (item.itemType === 'parent') { return ( ); } else if (item.itemType === 'visibility') { return ; } else if (item.itemType === 'pushNotifs') { return ; } else if (item.itemType === 'homeNotifs') { return ; } else if (item.itemType === 'seeMore') { return ; } else if (item.itemType === 'childThread') { return ( ); } else if (item.itemType === 'addSubchannel') { return ( ); } else if (item.itemType === 'member') { return ( ); } else if (item.itemType === 'addMember') { return ; } else if (item.itemType === 'mediaGallery') { return ( ); } else if (item.itemType === 'leaveThread') { return ( ); } else if (item.itemType === 'deleteThread') { return ( ); } else if (item.itemType === 'promoteSidebar') { return ( ); } else if (item.itemType === 'editRelationship') { return ( ); } else { invariant(false, `unexpected ThreadSettings item type ${item.itemType}`); } }; setNameEditValue = (value: ?string, callback?: () => void) => { this.setState({ nameEditValue: value }, callback); }; setColorEditValue = (color: string) => { this.setState({ colorEditValue: color }); }; setDescriptionEditValue = (value: ?string, callback?: () => void) => { this.setState({ descriptionEditValue: value }, callback); }; setDescriptionTextHeight = (height: number) => { this.setState({ descriptionTextHeight: height }); }; onPressComposeSubchannel = () => { this.props.navigation.navigate(ComposeSubchannelModalRouteName, { presentedFrom: this.props.route.key, threadInfo: this.props.threadInfo, }); }; onPressAddMember = () => { if (this.props.inviteLinkExists) { this.props.navigation.navigate(InviteLinkNavigatorRouteName, { screen: ViewInviteLinksRouteName, params: { community: this.props.threadInfo, }, }); } else if (this.props.canManageInviteLinks) { this.props.navigation.navigate(InviteLinkNavigatorRouteName, { screen: ManagePublicLinkRouteName, params: { community: this.props.threadInfo, }, }); } else { this.props.navigation.navigate(AddUsersModalRouteName, { presentedFrom: this.props.route.key, threadInfo: this.props.threadInfo, }); } }; onPressSeeMoreMembers = () => { this.setState(prevState => ({ numMembersShowing: prevState.numMembersShowing + itemPageLength, })); }; onPressSeeMoreSubchannels = () => { this.setState(prevState => ({ numSubchannelsShowing: prevState.numSubchannelsShowing + itemPageLength, })); }; onPressSeeMoreSidebars = () => { this.setState(prevState => ({ numSidebarsShowing: prevState.numSidebarsShowing + itemPageLength, })); }; onPressSeeMoreMediaGallery = () => { this.props.navigation.navigate(FullScreenThreadMediaGalleryRouteName, { threadInfo: this.props.threadInfo, }); }; } const threadMembersChangeIsSaving = ( state: AppState, threadMembers: $ReadOnlyArray, ) => { for (const threadMember of threadMembers) { const removeUserLoadingStatus = createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${threadMember.id}`, )(state); if (removeUserLoadingStatus === 'loading') { return true; } const changeRoleLoadingStatus = createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${threadMember.id}`, )(state); if (changeRoleLoadingStatus === 'loading') { return true; } } return false; }; const ConnectedThreadSettings: React.ComponentType = React.memo(function ConnectedThreadSettings(props: BaseProps) { const userInfos = useSelector(state => state.userStore.userInfos); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const threadID = props.route.params.threadInfo.id; const reduxThreadInfo: ?ThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); React.useEffect(() => { invariant( reduxThreadInfo, 'ReduxThreadInfo should exist when ThreadSettings is opened', ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const { setParams } = props.navigation; React.useEffect(() => { if (reduxThreadInfo) { setParams({ threadInfo: reduxThreadInfo }); } }, [reduxThreadInfo, setParams]); const threadInfo: ThreadInfo = reduxThreadInfo ?? props.route.params.threadInfo; const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); const isThreadInChatList = useIsThreadInChatList(threadInfo); React.useEffect(() => { if (isThreadInChatList) { return undefined; } threadWatcher.watchID(threadInfo.id); return () => { threadWatcher.removeID(threadInfo.id); }; }, [isThreadInChatList, threadInfo.id]); const parentThreadID = threadInfo.parentThreadID; const parentThreadInfo: ?ThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const resolvedParentThreadInfo = useResolvedOptionalThreadInfo(parentThreadInfo); const threadMembers = threadInfo.members; const boundChildThreadInfos = useSelector( state => childThreadInfos(state)[threadID], ); const resolvedChildThreadInfos = useResolvedOptionalThreadInfos( boundChildThreadInfos, ); const somethingIsSaving = useSelector(state => { const editNameLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:name`, )(state); const editColorLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:color`, )(state); const editDescriptionLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:description`, )(state); const leaveThreadLoadingStatus = createLoadingStatusSelector( leaveThreadActionTypes, `${leaveThreadActionTypes.started}:${threadID}`, )(state); const boundThreadMembersChangeIsSaving = threadMembersChangeIsSaving( state, threadMembers, ); return ( boundThreadMembersChangeIsSaving || editNameLoadingStatus === 'loading' || editColorLoadingStatus === 'loading' || editDescriptionLoadingStatus === 'loading' || leaveThreadLoadingStatus === 'loading' ); }); const { navigation } = props; React.useEffect(() => { const tabNavigation = navigation.getParent< ScreenParamList, 'Chat', TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, TabNavigationProp<'Chat'>, >(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); const onTabPress = () => { if (navigation.isFocused() && !somethingIsSaving) { navigation.popToTop(); } }; tabNavigation.addListener('tabPress', onTabPress); return () => tabNavigation.removeListener('tabPress', onTabPress); }, [navigation, somethingIsSaving]); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); const { canPromoteSidebar } = usePromoteSidebar(threadInfo); const canEditThreadAvatar = useThreadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_AVATAR, ); const canEditThreadName = useThreadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); const canEditThreadDescription = useThreadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const canEditThreadColor = useThreadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_COLOR, ); const canCreateSubchannels = useThreadHasPermission( threadInfo, threadPermissions.CREATE_SUBCHANNELS, ); const canLeaveThread = useThreadHasPermission( threadInfo, threadPermissions.LEAVE_THREAD, ); const canDeleteThread = useThreadHasPermission( threadInfo, threadPermissions.DELETE_THREAD, ); const { inviteLink, canManageLinks, canAddMembers, isCommunityRoot } = useAddUsersPermissions(threadInfo); const callFetchPrimaryLinks = useFetchPrimaryInviteLinks(); const dispatchActionPromise = useDispatchActionPromise(); // Because we don't support updates and persistance for invite links, // we have to fetch them whenever we want to display them. // Here we need invite links for the "Add users" button in ThreadSettings React.useEffect(() => { if (!isCommunityRoot) { return; } void dispatchActionPromise( fetchPrimaryInviteLinkActionTypes, callFetchPrimaryLinks(), ); }, [callFetchPrimaryLinks, dispatchActionPromise, isCommunityRoot]); return ( ); }); export default ConnectedThreadSettings; diff --git a/native/components/thread-icon.react.js b/native/components/thread-icon.react.js index 3d7d70519..ce1ccb31a 100644 --- a/native/components/thread-icon.react.js +++ b/native/components/thread-icon.react.js @@ -1,44 +1,44 @@ // @flow import EntypoIcon from '@expo/vector-icons/Entypo.js'; import * as React from 'react'; import { StyleSheet } from 'react-native'; import { threadTypes, type ThreadType } from 'lib/types/thread-types-enum.js'; import SWMansionIcon from './swmansion-icon.react.js'; type Props = { +threadType: ThreadType, +color: string, }; function ThreadIcon(props: Props): React.Node { const { threadType, color } = props; if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ) { return ; } else if (threadType === threadTypes.SIDEBAR) { return ( ); - } else if (threadType === threadTypes.PERSONAL) { + } else if (threadType === threadTypes.GENESIS_PERSONAL) { return ; } else { return ; } } const styles = StyleSheet.create({ sidebarIcon: { paddingTop: 2, }, }); export default ThreadIcon; diff --git a/native/redux/edit-thread-permission-migration.js b/native/redux/edit-thread-permission-migration.js index ecee1b151..97d05bfbf 100644 --- a/native/redux/edit-thread-permission-migration.js +++ b/native/redux/edit-thread-permission-migration.js @@ -1,96 +1,96 @@ // @flow import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { LegacyMemberInfo, LegacyRawThreadInfo, ClientLegacyRoleInfo, LegacyRawThreadInfos, LegacyThreadCurrentUserInfo, } from 'lib/types/thread-types.js'; function addDetailedThreadEditPermissionsToUser< T: LegacyMemberInfo | LegacyThreadCurrentUserInfo, >(threadInfo: LegacyRawThreadInfo, member: T, threadID: string): T { let newPermissions = null; - if (threadInfo.type === threadTypes.PRIVATE) { + if (threadInfo.type === threadTypes.GENESIS_PRIVATE) { newPermissions = { ...member.permissions, edit_thread_color: { value: true, source: threadID }, edit_thread_description: { value: true, source: threadID }, }; } else if (member.permissions['edit_thread']) { newPermissions = { ...member.permissions, edit_thread_color: member.permissions['edit_thread'], edit_thread_description: member.permissions['edit_thread'], }; } return newPermissions ? { ...member, permissions: newPermissions, } : member; } function addDetailedThreadEditPermissionsToRole( role: ClientLegacyRoleInfo, threadType: number, ): ClientLegacyRoleInfo { let updatedPermissions = null; if (role.permissions['edit_thread']) { updatedPermissions = { ...role.permissions, edit_thread_color: role.permissions['edit_thread'], edit_thread_description: role.permissions['edit_thread'], }; - } else if (threadType === threadTypes.PRIVATE) { + } else if (threadType === threadTypes.GENESIS_PRIVATE) { updatedPermissions = { ...role.permissions, edit_thread_color: true, edit_thread_description: true, }; } return updatedPermissions ? { ...role, permissions: updatedPermissions } : role; } function migrateThreadStoreForEditThreadPermissions(threadInfos: { +[id: string]: LegacyRawThreadInfo, }): LegacyRawThreadInfos { const newThreadInfos: { [string]: LegacyRawThreadInfo } = {}; for (const threadID in threadInfos) { const threadInfo: LegacyRawThreadInfo = threadInfos[threadID]; const updatedMembers = threadInfo.members.map(member => addDetailedThreadEditPermissionsToUser(threadInfo, member, threadID), ); const updatedCurrentUser = addDetailedThreadEditPermissionsToUser( threadInfo, threadInfo.currentUser, threadID, ); const updatedRoles: { [string]: ClientLegacyRoleInfo } = {}; for (const roleID in threadInfo.roles) { updatedRoles[roleID] = addDetailedThreadEditPermissionsToRole( threadInfo.roles[roleID], threadInfo.type, ); } const newThreadInfo = { ...threadInfo, members: updatedMembers, currentUser: updatedCurrentUser, roles: updatedRoles, }; newThreadInfos[threadID] = newThreadInfo; } return newThreadInfos; } export { migrateThreadStoreForEditThreadPermissions }; diff --git a/web/avatars/thread-avatar.react.js b/web/avatars/thread-avatar.react.js index b9870643b..f3c663230 100644 --- a/web/avatars/thread-avatar.react.js +++ b/web/avatars/thread-avatar.react.js @@ -1,57 +1,57 @@ // @flow import * as React from 'react'; import { useAvatarForThread } from 'lib/hooks/avatar-hooks.js'; import { useENSResolvedAvatar } from 'lib/shared/avatar-utils.js'; import { getSingleOtherUser } from 'lib/shared/thread-utils.js'; import type { AvatarSize } from 'lib/types/avatar-types.js'; import type { ThreadInfo, RawThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import Avatar from './avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { +threadInfo: RawThreadInfo | ThreadInfo, +size: AvatarSize, +showSpinner?: boolean, }; function ThreadAvatar(props: Props): React.Node { const { threadInfo, size, showSpinner } = props; const avatarInfo = useAvatarForThread(threadInfo); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); let displayUserIDForThread; - if (threadInfo.type === threadTypes.PRIVATE) { + if (threadInfo.type === threadTypes.GENESIS_PRIVATE) { displayUserIDForThread = viewerID; - } else if (threadInfo.type === threadTypes.PERSONAL) { + } else if (threadInfo.type === threadTypes.GENESIS_PERSONAL) { displayUserIDForThread = getSingleOtherUser(threadInfo, viewerID); } const displayUser = useSelector(state => displayUserIDForThread ? state.userStore.userInfos[displayUserIDForThread] : null, ); const resolvedThreadAvatar = useENSResolvedAvatar(avatarInfo, displayUser); return ( ); } export default ThreadAvatar; diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js index 4070301a4..8b9d0929b 100644 --- a/web/chat/chat-message-list.react.js +++ b/web/chat/chat-message-list.react.js @@ -1,512 +1,512 @@ // @flow import classNames from 'classnames'; import { detect as detectBrowser } from 'detect-browser'; import invariant from 'invariant'; import _debounce from 'lodash/debounce.js'; import * as React from 'react'; import type { FetchMessagesBeforeCursorInput, FetchMostRecentMessagesInput, } from 'lib/actions/message-actions.js'; import { fetchMessagesBeforeCursorActionTypes, fetchMostRecentMessagesActionTypes, useFetchMessagesBeforeCursor, useFetchMostRecentMessages, } from 'lib/actions/message-actions.js'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { useOldestMessageServerID } from 'lib/hooks/message-hooks.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { type ChatMessageItem, useMessageListData, } from 'lib/selectors/chat-selectors.js'; import { messageKey } from 'lib/shared/message-utils.js'; import { threadIsPending, threadOtherMembers, } from 'lib/shared/thread-utils.js'; import type { FetchMessageInfosPayload } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { defaultMaxTextAreaHeight, editBoxHeight } from './chat-constants.js'; import css from './chat-message-list.css'; import type { ScrollToMessageCallback } from './edit-message-provider.js'; import { useEditModalContext } from './edit-message-provider.js'; import { MessageListContext } from './message-list-types.js'; import Message from './message.react.js'; import RelationshipPrompt from './relationship-prompt/relationship-prompt.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import LoadingIndicator from '../loading-indicator.react.js'; import { useTextMessageRulesFunc } from '../markdown/rules.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useTooltipContext } from '../tooltips/tooltip-provider.js'; const browser = detectBrowser(); const supportsReverseFlex = !browser || browser.name !== 'firefox' || parseInt(browser.version) >= 81; // Margin between the top of the maximum height edit box // and the top of the container const editBoxTopMargin = 10; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, +activeChatThreadID: ?string, +messageListData: ?$ReadOnlyArray, +startReached: boolean, +dispatchActionPromise: DispatchActionPromise, +fetchMessagesBeforeCursor: ( input: FetchMessagesBeforeCursorInput, ) => Promise, +fetchMostRecentMessages: ( input: FetchMostRecentMessagesInput, ) => Promise, +inputState: ?InputState, +clearTooltip: () => mixed, +oldestMessageServerID: ?string, +isEditState: boolean, +addScrollToMessageListener: ScrollToMessageCallback => mixed, +removeScrollToMessageListener: ScrollToMessageCallback => mixed, +viewerID: ?string, }; type Snapshot = { +scrollTop: number, +scrollHeight: number, }; type State = { +scrollingEndCallback: ?() => mixed, }; class ChatMessageList extends React.PureComponent { container: ?HTMLDivElement; messageContainer: ?HTMLDivElement; loadingFromScroll = false; constructor(props: Props) { super(props); this.state = { scrollingEndCallback: null, }; } componentDidMount() { this.scrollToBottom(); this.props.addScrollToMessageListener(this.scrollToMessage); } componentWillUnmount() { this.props.removeScrollToMessageListener(this.scrollToMessage); } getSnapshotBeforeUpdate(prevProps: Props): ?Snapshot { if ( ChatMessageList.hasNewMessage(this.props, prevProps) && this.messageContainer ) { const { scrollTop, scrollHeight } = this.messageContainer; return { scrollTop, scrollHeight }; } return null; } static hasNewMessage(props: Props, prevProps: Props): boolean { const { messageListData } = props; if (!messageListData || messageListData.length === 0) { return false; } const prevMessageListData = prevProps.messageListData; if (!prevMessageListData || prevMessageListData.length === 0) { return true; } return ( ChatMessageList.keyExtractor(prevMessageListData[0]) !== ChatMessageList.keyExtractor(messageListData[0]) ); } componentDidUpdate(prevProps: Props, prevState: State, snapshot: ?Snapshot) { const { messageListData } = this.props; const prevMessageListData = prevProps.messageListData; const { messageContainer } = this; if (messageContainer && prevMessageListData !== messageListData) { this.onScroll(); } // We'll scroll to the bottom if the user was already scrolled to the bottom // before the new message, or if the new message was composed locally const hasNewMessage = ChatMessageList.hasNewMessage(this.props, prevProps); if ( this.props.activeChatThreadID !== prevProps.activeChatThreadID || (hasNewMessage && messageListData && messageListData[0].itemType === 'message' && messageListData[0].messageInfo.localID) || (hasNewMessage && snapshot && Math.abs(snapshot.scrollTop) <= 1 && !this.props.isEditState) ) { this.scrollToBottom(); } else if (hasNewMessage && messageContainer && snapshot) { const { scrollTop, scrollHeight } = messageContainer; if ( scrollHeight > snapshot.scrollHeight && scrollTop === snapshot.scrollTop ) { const newHeight = scrollHeight - snapshot.scrollHeight; const newScrollTop = Math.abs(scrollTop) + newHeight; if (supportsReverseFlex) { messageContainer.scrollTop = -1 * newScrollTop; } else { messageContainer.scrollTop = newScrollTop; } } } } scrollToBottom() { if (this.messageContainer) { this.messageContainer.scrollTop = 0; } } static keyExtractor(item: ChatMessageItem): string { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } renderItem = (item: ChatMessageItem): React.Node => { if (item.itemType === 'loader') { return (
); } const { threadInfo } = this.props; invariant(threadInfo, 'ThreadInfo should be set if messageListData is'); return ( ); }; scrollingEndCallbackWrapper = ( composedMessageID: string, callback: (maxHeight: number) => mixed, ): (() => mixed) => { return () => { const maxHeight = this.getMaxEditTextAreaHeight(composedMessageID); callback(maxHeight); }; }; scrollToMessage = ( composedMessageID: string, callback: (maxHeight: number) => mixed, ) => { const element = document.getElementById(composedMessageID); if (!element) { return; } const scrollingEndCallback = this.scrollingEndCallbackWrapper( composedMessageID, callback, ); if (!this.willMessageEditWindowOverflow(composedMessageID)) { scrollingEndCallback(); return; } this.setState( { scrollingEndCallback, }, () => { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); // It covers the case when browser decide not to scroll to the message // because it's already in the view. // In this case, the 'scroll' event won't be triggered, // so we need to call the callback manually. this.debounceEditModeAfterScrollToMessage(); }, ); }; getMaxEditTextAreaHeight = (composedMessageID: string): number => { const { messageContainer } = this; if (!messageContainer) { return defaultMaxTextAreaHeight; } const messageElement = document.getElementById(composedMessageID); if (!messageElement) { console.log(`couldn't find the message element`); return defaultMaxTextAreaHeight; } const msgPos = messageElement.getBoundingClientRect(); const containerPos = messageContainer.getBoundingClientRect(); const messageBottom = msgPos.bottom; const containerTop = containerPos.top; const maxHeight = messageBottom - containerTop - editBoxHeight - editBoxTopMargin; return maxHeight; }; willMessageEditWindowOverflow(composedMessageID: string): boolean { const { messageContainer } = this; if (!messageContainer) { return false; } const messageElement = document.getElementById(composedMessageID); if (!messageElement) { console.log(`couldn't find the message element`); return false; } const msgPos = messageElement.getBoundingClientRect(); const containerPos = messageContainer.getBoundingClientRect(); const containerTop = containerPos.top; const containerBottom = containerPos.bottom; const availableTextAreaHeight = (containerBottom - containerTop) / 2 - editBoxHeight; const messageHeight = msgPos.height; const expectedMinimumHeight = Math.min( defaultMaxTextAreaHeight, availableTextAreaHeight, ); const offset = Math.max( 0, expectedMinimumHeight + editBoxHeight + editBoxTopMargin - messageHeight, ); const messageTop = msgPos.top - offset; const messageBottom = msgPos.bottom; return messageBottom > containerBottom || messageTop < containerTop; } render(): React.Node { const { messageListData, threadInfo, inputState, isEditState } = this.props; if (!messageListData) { return
; } invariant(inputState, 'InputState should be set'); const messages = messageListData.map(this.renderItem); let relationshipPrompt = null; - if (threadInfo.type === threadTypes.PERSONAL) { + if (threadInfo.type === threadTypes.GENESIS_PERSONAL) { const otherMembers = threadOtherMembers( threadInfo.members, this.props.viewerID, ); let pendingPersonalThreadUserInfo; if (otherMembers.length === 1) { const otherMember = otherMembers[0]; pendingPersonalThreadUserInfo = { id: otherMember.id, username: otherMember.username, }; } relationshipPrompt = ( ); } const messageContainerStyle = classNames({ [css.disableAnchor]: this.state.scrollingEndCallback !== null || isEditState, [css.messageContainer]: true, [css.mirroredMessageContainer]: !supportsReverseFlex, }); return (
{relationshipPrompt}
{messages}
); } messageContainerRef = (messageContainer: ?HTMLDivElement) => { this.messageContainer = messageContainer; // In case we already have all the most recent messages, // but they're not enough void this.possiblyLoadMoreMessages(); if (messageContainer) { messageContainer.addEventListener('scroll', this.onScroll); } }; onScroll = () => { if (!this.messageContainer) { return; } this.props.clearTooltip(); void this.possiblyLoadMoreMessages(); this.debounceEditModeAfterScrollToMessage(); }; debounceEditModeAfterScrollToMessage: () => void = _debounce(() => { if (this.state.scrollingEndCallback) { this.state.scrollingEndCallback(); } this.setState({ scrollingEndCallback: null }); }, 100); async possiblyLoadMoreMessages() { if (!this.messageContainer) { return; } const { scrollTop, scrollHeight, clientHeight } = this.messageContainer; if ( this.props.startReached || Math.abs(scrollTop) + clientHeight + 55 < scrollHeight ) { return; } if (this.loadingFromScroll) { return; } this.loadingFromScroll = true; const threadID = this.props.activeChatThreadID; invariant(threadID, 'should be set'); try { const { oldestMessageServerID } = this.props; if (oldestMessageServerID) { await this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor({ threadID, beforeMessageID: oldestMessageServerID, }), ); } else { await this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, this.props.fetchMostRecentMessages({ threadID }), ); } } finally { this.loadingFromScroll = false; } } } registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); const ConnectedChatMessageList: React.ComponentType = React.memo(function ConnectedChatMessageList( props: BaseProps, ): React.Node { const { threadInfo } = props; const messageListData = useMessageListData({ threadInfo, searching: false, userInfoInputArray: [], }); const startReached = !!useSelector(state => { const activeID = threadInfo.id; if (!activeID) { return null; } if (threadIsPending(activeID)) { return true; } const threadMessageInfo = state.messageStore.threads[activeID]; if (!threadMessageInfo) { return null; } return threadMessageInfo.startReached; }); const dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useFetchMessagesBeforeCursor(); const callFetchMostRecentMessages = useFetchMostRecentMessages(); const inputState = React.useContext(InputStateContext); const { clearTooltip } = useTooltipContext(); const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const getTextMessageMarkdownRules = useTextMessageRulesFunc( threadInfo, chatMentionCandidates, ); const messageListContext = React.useMemo(() => { if (!getTextMessageMarkdownRules) { return undefined; } return { getTextMessageMarkdownRules }; }, [getTextMessageMarkdownRules]); const oldestMessageServerID = useOldestMessageServerID(threadInfo.id); const { editState, addScrollToMessageListener, removeScrollToMessageListener, } = useEditModalContext(); const isEditState = editState !== null; const viewerID = useSelector(state => state.currentUserInfo?.id); return ( ); }); export default ConnectedChatMessageList; diff --git a/web/chat/chat-thread-composer.react.js b/web/chat/chat-thread-composer.react.js index e5cb2ada6..aee35ce4c 100644 --- a/web/chat/chat-thread-composer.react.js +++ b/web/chat/chat-thread-composer.react.js @@ -1,249 +1,249 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/swmansion-icon.react.js'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { usePotentialMemberItems, useSearchUsers, notFriendNotice, } from 'lib/shared/search-utils.js'; import { createPendingThread, threadIsPending, useExistingThreadInfoFinder, } from 'lib/shared/thread-utils.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import css from './chat-thread-composer.css'; import UserAvatar from '../avatars/user-avatar.react.js'; import Button from '../components/button.react.js'; import Label from '../components/label.react.js'; import Search from '../components/search.react.js'; import type { InputState } from '../input/input-state.js'; import Alert from '../modals/alert.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; type Props = { +userInfoInputArray: $ReadOnlyArray, +otherUserInfos: { [id: string]: AccountUserInfo }, +threadID: string, +inputState: InputState, }; type ActiveThreadBehavior = | 'reset-active-thread-if-pending' | 'keep-active-thread'; function ChatThreadComposer(props: Props): React.Node { const { userInfoInputArray, otherUserInfos, threadID, inputState } = props; const [usernameInputText, setUsernameInputText] = React.useState(''); const dispatch = useDispatch(); const userInfoInputIDs = React.useMemo( () => userInfoInputArray.map(userInfo => userInfo.id), [userInfoInputArray], ); const searchResults = useSearchUsers(usernameInputText); const userListItems = usePotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, excludeUserIDs: userInfoInputIDs, includeServerSearchUsers: searchResults, }); const userListItemsWithENSNames = useENSNames(userListItems); const { pushModal } = useModalContext(); const loggedInUserInfo = useLoggedInUserInfo(); invariant(loggedInUserInfo, 'loggedInUserInfo should be set'); const pendingPrivateThread = React.useRef( createPendingThread({ viewerID: loggedInUserInfo.id, - threadType: threadTypes.PRIVATE, + threadType: threadTypes.GENESIS_PRIVATE, members: [loggedInUserInfo], }), ); const existingThreadInfoFinderForCreatingThread = useExistingThreadInfoFinder( pendingPrivateThread.current, ); const onSelectUserFromSearch = React.useCallback( (userListItem: UserListItem) => { const { alert, notice, disabled, ...user } = userListItem; setUsernameInputText(''); if (!alert) { dispatch({ type: updateNavInfoActionType, payload: { selectedUserList: [...userInfoInputArray, user], }, }); } else if ( notice === notFriendNotice && userInfoInputArray.length === 0 ) { const newUserInfoInputArray = [ { id: userListItem.id, username: userListItem.username }, ]; const threadInfo = existingThreadInfoFinderForCreatingThread({ searching: true, userInfoInputArray: newUserInfoInputArray, }); dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'view', activeChatThreadID: threadInfo?.id, pendingThread: threadInfo, }, }); } else { pushModal({alert.text}); } }, [ dispatch, existingThreadInfoFinderForCreatingThread, pushModal, userInfoInputArray, ], ); const onRemoveUserFromSelected = React.useCallback( (userID: string) => { const newSelectedUserList = userInfoInputArray.filter( ({ id }) => userID !== id, ); if (_isEqual(userInfoInputArray)(newSelectedUserList)) { return; } dispatch({ type: updateNavInfoActionType, payload: { selectedUserList: newSelectedUserList, }, }); }, [dispatch, userInfoInputArray], ); const userSearchResultList = React.useMemo(() => { if ( !userListItemsWithENSNames.length || (!usernameInputText && userInfoInputArray.length) ) { return null; } const userItems = userListItemsWithENSNames.map( (userSearchResult: UserListItem) => { return (
  • ); }, ); return
      {userItems}
    ; }, [ onSelectUserFromSearch, userInfoInputArray.length, userListItemsWithENSNames, usernameInputText, ]); const hideSearch = React.useCallback( (threadBehavior: ActiveThreadBehavior = 'keep-active-thread') => { dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'view', activeChatThreadID: threadBehavior === 'keep-active-thread' || !threadIsPending(threadID) ? threadID : null, }, }); }, [dispatch, threadID], ); const onCloseSearch = React.useCallback(() => { hideSearch('reset-active-thread-if-pending'); }, [hideSearch]); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); const tagsList = React.useMemo(() => { if (!userInfoInputArrayWithENSNames?.length) { return null; } const labels = userInfoInputArrayWithENSNames.map(user => { return ( ); }); return
    {labels}
    ; }, [userInfoInputArrayWithENSNames, onRemoveUserFromSelected]); React.useEffect(() => { if (!inputState) { return undefined; } inputState.registerSendCallback(hideSearch); return () => inputState.unregisterSendCallback(hideSearch); }, [hideSearch, inputState]); const threadSearchContainerStyles = classNames(css.threadSearchContainer, { [css.fullHeight]: !userInfoInputArray.length, }); return (
    {tagsList} {userSearchResultList}
    ); } export default ChatThreadComposer; diff --git a/web/chat/thread-menu.react.js b/web/chat/thread-menu.react.js index 17213f1b2..2226f6277 100644 --- a/web/chat/thread-menu.react.js +++ b/web/chat/thread-menu.react.js @@ -1,314 +1,314 @@ // @flow import * as React from 'react'; import { leaveThreadActionTypes, useLeaveThread, } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/swmansion-icon.react.js'; import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react.js'; import { childThreadInfos } from 'lib/selectors/thread-selectors.js'; import { threadIsChannel, useThreadHasPermission, viewerIsMember, } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import css from './thread-menu.css'; import MenuItem from '../components/menu-item.react.js'; import Menu from '../components/menu.react.js'; import SidebarPromoteModal from '../modals/chat/sidebar-promote-modal.react.js'; import ConfirmLeaveThreadModal from '../modals/threads/confirm-leave-thread-modal.react.js'; import ComposeSubchannelModal from '../modals/threads/create/compose-subchannel-modal.react.js'; import ThreadSettingsMediaGalleryModal from '../modals/threads/gallery/thread-settings-media-gallery.react.js'; import ThreadMembersModal from '../modals/threads/members/members-modal.react.js'; import ThreadNotificationsModal from '../modals/threads/notifications/notifications-modal.react.js'; import ThreadSettingsModal from '../modals/threads/settings/thread-settings-modal.react.js'; import SidebarsModal from '../modals/threads/sidebars/sidebars-modal.react.js'; import SubchannelsModal from '../modals/threads/subchannels/subchannels-modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; type ThreadMenuProps = { +threadInfo: ThreadInfo, }; function ThreadMenu(props: ThreadMenuProps): React.Node { const { pushModal, popModal } = useModalContext(); const { threadInfo } = props; const { onPromoteSidebar, canPromoteSidebar } = usePromoteSidebar(threadInfo); const onClickSettings = React.useCallback( () => pushModal(), [pushModal, threadInfo.id], ); const settingsItem = React.useMemo(() => { return ( ); }, [onClickSettings]); const onClickMembers = React.useCallback( () => pushModal( , ), [popModal, pushModal, threadInfo.id], ); const membersItem = React.useMemo(() => { - if (threadInfo.type === threadTypes.PERSONAL) { + if (threadInfo.type === threadTypes.GENESIS_PERSONAL) { return null; } return ( ); }, [onClickMembers, threadInfo.type]); const onClickThreadMediaGallery = React.useCallback( () => pushModal( , ), [popModal, pushModal, threadInfo], ); const threadMediaGalleryItem = React.useMemo( () => ( ), [onClickThreadMediaGallery], ); const childThreads = useSelector( state => childThreadInfos(state)[threadInfo.id], ); const hasSidebars = React.useMemo(() => { return childThreads?.some( childThreadInfo => childThreadInfo.type === threadTypes.SIDEBAR, ); }, [childThreads]); const onClickSidebars = React.useCallback( () => pushModal(), [popModal, pushModal, threadInfo.id], ); const sidebarItem = React.useMemo(() => { if (!hasSidebars) { return null; } return ( ); }, [hasSidebars, onClickSidebars]); const canCreateSubchannels = useThreadHasPermission( threadInfo, threadPermissions.CREATE_SUBCHANNELS, ); const hasSubchannels = React.useMemo(() => { return !!childThreads?.some(threadIsChannel); }, [childThreads]); const onClickViewSubchannels = React.useCallback( () => pushModal( , ), [popModal, pushModal, threadInfo.id], ); const viewSubchannelsItem = React.useMemo(() => { if (!hasSubchannels) { return null; } return ( ); }, [hasSubchannels, onClickViewSubchannels]); const onClickCreateSubchannel = React.useCallback( () => pushModal( , ), [popModal, pushModal, threadInfo], ); const createSubchannelsItem = React.useMemo(() => { if (!canCreateSubchannels) { return null; } return ( ); }, [canCreateSubchannels, onClickCreateSubchannel]); const dispatchActionPromise = useDispatchActionPromise(); const callLeaveThread = useLeaveThread(); const onConfirmLeaveThread = React.useCallback(() => { void dispatchActionPromise( leaveThreadActionTypes, callLeaveThread({ threadID: threadInfo.id }), ); popModal(); }, [callLeaveThread, popModal, dispatchActionPromise, threadInfo.id]); const onClickLeaveThread = React.useCallback( () => pushModal( , ), [popModal, onConfirmLeaveThread, pushModal, threadInfo], ); const canLeaveThread = useThreadHasPermission( threadInfo, threadPermissions.LEAVE_THREAD, ); const leaveThreadItem = React.useMemo(() => { if (!viewerIsMember(threadInfo) || !canLeaveThread) { return null; } return ( ); }, [onClickLeaveThread, threadInfo, canLeaveThread]); const onClickPromoteSidebarToThread = React.useCallback( () => pushModal( , ), [pushModal, threadInfo, popModal, onPromoteSidebar], ); const promoteSidebar = React.useMemo(() => { return ( ); }, [onClickPromoteSidebarToThread]); const onClickNotifications = React.useCallback(() => { pushModal( , ); }, [popModal, pushModal, threadInfo.id]); const notificationsItem = React.useMemo(() => { if (!viewerIsMember(threadInfo)) { return null; } return ( ); }, [onClickNotifications, threadInfo]); const menuItems = React.useMemo(() => { const separator =
    ; const items = [ settingsItem, notificationsItem, membersItem, threadMediaGalleryItem, sidebarItem, viewSubchannelsItem, createSubchannelsItem, leaveThreadItem && separator, canPromoteSidebar && promoteSidebar, leaveThreadItem, ]; return items.filter(Boolean); }, [ settingsItem, notificationsItem, membersItem, threadMediaGalleryItem, sidebarItem, viewSubchannelsItem, promoteSidebar, createSubchannelsItem, leaveThreadItem, canPromoteSidebar, ]); const icon = React.useMemo( () => , [], ); return {menuItems}; } export default ThreadMenu; diff --git a/web/utils/thread-utils.js b/web/utils/thread-utils.js index 394769a19..850d9d4ec 100644 --- a/web/utils/thread-utils.js +++ b/web/utils/thread-utils.js @@ -1,110 +1,110 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { createPendingThread, useExistingThreadInfoFinder, } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { AccountUserInfo } from 'lib/types/user-types.js'; import { useSelector } from '../redux/redux-utils.js'; type InfosForPendingThread = { +isChatCreation: boolean, +selectedUserInfos: $ReadOnlyArray, +otherUserInfos: { [id: string]: AccountUserInfo }, }; function useInfosForPendingThread(): InfosForPendingThread { const isChatCreation = useSelector( state => state.navInfo.chatMode === 'create', ); const selectedUserInfos = useSelector( state => state.navInfo.selectedUserList ?? [], ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); return { isChatCreation, selectedUserInfos, otherUserInfos, }; } function useThreadInfoForPossiblyPendingThread( activeChatThreadID: ?string, ): ?ThreadInfo { const { isChatCreation, selectedUserInfos } = useInfosForPendingThread(); const loggedInUserInfo = useLoggedInUserInfo(); invariant(loggedInUserInfo, 'loggedInUserInfo should be set'); const pendingPrivateThread = React.useRef( createPendingThread({ viewerID: loggedInUserInfo.id, - threadType: threadTypes.PRIVATE, + threadType: threadTypes.GENESIS_PRIVATE, members: [loggedInUserInfo], }), ); const newThreadID = 'pending/new_thread'; const pendingNewThread = React.useMemo( () => ({ ...createPendingThread({ viewerID: loggedInUserInfo.id, - threadType: threadTypes.PRIVATE, + threadType: threadTypes.GENESIS_PRIVATE, members: [loggedInUserInfo], name: 'New thread', }), id: newThreadID, }), [loggedInUserInfo], ); const existingThreadInfoFinderForCreatingThread = useExistingThreadInfoFinder( pendingPrivateThread.current, ); const baseThreadInfo = useSelector(state => { if (activeChatThreadID) { const activeThreadInfo = threadInfoSelector(state)[activeChatThreadID]; if (activeThreadInfo) { return activeThreadInfo; } } return state.navInfo.pendingThread; }); const existingThreadInfoFinder = useExistingThreadInfoFinder(baseThreadInfo); const threadInfo = React.useMemo(() => { if (isChatCreation) { if (selectedUserInfos.length === 0) { return pendingNewThread; } return existingThreadInfoFinderForCreatingThread({ searching: true, userInfoInputArray: selectedUserInfos, }); } return existingThreadInfoFinder({ searching: false, userInfoInputArray: [], }); }, [ existingThreadInfoFinder, existingThreadInfoFinderForCreatingThread, isChatCreation, pendingNewThread, selectedUserInfos, ]); return threadInfo; } export { useThreadInfoForPossiblyPendingThread, useInfosForPendingThread };