diff --git a/lib/permissions/minimally-encoded-thread-permissions.test.js b/lib/permissions/minimally-encoded-thread-permissions.test.js index aa4efa0cd..2d7f2a519 100644 --- a/lib/permissions/minimally-encoded-thread-permissions.test.js +++ b/lib/permissions/minimally-encoded-thread-permissions.test.js @@ -1,667 +1,671 @@ // @flow import { memberInfoWithPermissionsValidator, persistedRoleInfoValidator, rawThreadInfoValidator, roleInfoValidator, threadCurrentUserInfoValidator, } from './minimally-encoded-raw-thread-info-validators.js'; import { exampleMinimallyEncodedRawThreadInfoA, exampleRawThreadInfoA, expectedDecodedExampleRawThreadInfoA, } from './minimally-encoded-thread-permissions-test-data.js'; import { decodeRolePermissionBitmask, decodeThreadRolePermissionsBitmaskArray, hasPermission, permissionsToBitmaskHex, rolePermissionToBitmaskHex, threadPermissionsFromBitmaskHex, threadRolePermissionsBlobToBitmaskArray, } from './minimally-encoded-thread-permissions.js'; import { specialRoles } from './special-roles.js'; import { - deprecatedMinimallyEncodeRawThreadInfo, + minimallyEncodeRawThreadInfoWithMemberPermissions, deprecatedDecodeMinimallyEncodedRawThreadInfo, minimallyEncodeThreadCurrentUserInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { ThreadRolePermissionsBlob } from '../types/thread-permission-types.js'; import type { LegacyThreadCurrentUserInfo } from '../types/thread-types.js'; const 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: false, source: null }, edit_permissions: { value: false, source: null }, 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: true, source: '1' }, edit_message: { value: true, source: '1' }, edit_thread_avatar: { value: false, source: null }, manage_pins: { value: false, source: null }, manage_invite_links: { value: false, source: null }, voiced_in_announcement_channels: { value: false, source: null }, manage_farcaster_channel_tags: { value: false, source: null }, }; describe('minimallyEncodedThreadPermissions', () => { it('should encode ThreadPermissionsInfo as bitmask', () => { const permissionsBitmask = permissionsToBitmaskHex(permissions); expect(permissionsBitmask).toBe('373ff'); expect(hasPermission(permissionsBitmask, 'know_of')).toBe(true); expect(hasPermission(permissionsBitmask, 'visible')).toBe(true); expect(hasPermission(permissionsBitmask, 'voiced')).toBe(true); expect(hasPermission(permissionsBitmask, 'edit_entries')).toBe(true); expect(hasPermission(permissionsBitmask, 'edit_thread')).toBe(true); expect(hasPermission(permissionsBitmask, 'edit_thread_description')).toBe( true, ); expect(hasPermission(permissionsBitmask, 'edit_thread_color')).toBe(true); expect(hasPermission(permissionsBitmask, 'delete_thread')).toBe(true); expect(hasPermission(permissionsBitmask, 'create_subthreads')).toBe(true); expect(hasPermission(permissionsBitmask, 'create_sidebars')).toBe(true); expect(hasPermission(permissionsBitmask, 'join_thread')).toBe(false); expect(hasPermission(permissionsBitmask, 'edit_permissions')).toBe(false); expect(hasPermission(permissionsBitmask, 'remove_members')).toBe(true); expect(hasPermission(permissionsBitmask, 'change_role')).toBe(true); expect(hasPermission(permissionsBitmask, 'leave_thread')).toBe(false); expect(hasPermission(permissionsBitmask, 'react_to_message')).toBe(true); expect(hasPermission(permissionsBitmask, 'edit_message')).toBe(true); }); }); describe('hasPermission', () => { const permissionsSansKnowOf = { know_of: { value: false, source: null }, visible: { value: true, source: '1' }, }; const permissionsSansKnowOfBitmask = permissionsToBitmaskHex( permissionsSansKnowOf, ); it('should fail check if know_of is false even if permission specified in request is true', () => { expect(hasPermission(permissionsSansKnowOfBitmask, 'visible')).toBe(false); }); const permissionsWithKnowOf = { know_of: { value: true, source: '1' }, visible: { value: true, source: '1' }, }; const permissionsWithKnowOfBitmask = permissionsToBitmaskHex( permissionsWithKnowOf, ); it('should succeed permission check if know_of is true', () => { expect(hasPermission(permissionsWithKnowOfBitmask, 'visible')).toBe(true); }); }); describe('threadPermissionsFromBitmaskHex', () => { const expectedDecodedThreadPermissions = { 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: false, source: null }, edit_permissions: { value: false, 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: true, source: 'null' }, edit_message: { value: true, source: 'null' }, edit_thread_avatar: { value: false, source: null }, manage_pins: { value: false, source: null }, manage_invite_links: { value: false, source: null }, voiced_in_announcement_channels: { value: false, source: null }, manage_farcaster_channel_tags: { value: false, source: null }, }; it('should decode ThreadPermissionsInfo from bitmask', () => { const permissionsBitmask = permissionsToBitmaskHex(permissions); const decodedThreadPermissions = threadPermissionsFromBitmaskHex(permissionsBitmask); expect(decodedThreadPermissions).toStrictEqual( expectedDecodedThreadPermissions, ); }); it('should decode bitmask strings under 3 characters', () => { // We know that '3' in hex is 0b0011. Given that permissions are encoded // from least significant bit (LSB) to most significant bit (MSB), we would // except this to mean that only the first two permissions listed in // `baseRolePermissionEncoding` are `true`. Which is the case. const decodedThreadPermissions = threadPermissionsFromBitmaskHex('3'); expect(decodedThreadPermissions).toStrictEqual({ know_of: { value: true, source: 'null' }, visible: { value: true, source: 'null' }, voiced: { value: false, source: null }, edit_entries: { value: false, source: null }, edit_thread: { value: false, source: null }, edit_thread_description: { value: false, source: null }, edit_thread_color: { value: false, source: null }, delete_thread: { value: false, source: null }, create_subthreads: { value: false, source: null }, create_sidebars: { value: false, 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: false, source: null }, edit_message: { value: false, source: null }, edit_thread_avatar: { value: false, source: null }, manage_pins: { value: false, source: null }, manage_invite_links: { value: false, source: null }, voiced_in_announcement_channels: { value: false, source: null }, manage_farcaster_channel_tags: { value: false, source: null }, }); }); }); describe('rolePermissionToBitmaskHex', () => { it('should encode `child_opentoplevel_visible` successfully', () => { expect(rolePermissionToBitmaskHex('child_opentoplevel_visible')).toBe( '01b', ); }); it('should encode `child_opentoplevel_know_of` successfully', () => { expect(rolePermissionToBitmaskHex('child_opentoplevel_know_of')).toBe( '00b', ); }); it('should encode `child_toplevel_visible` successfully', () => { expect(rolePermissionToBitmaskHex('child_toplevel_visible')).toBe('01a'); }); it('should encode `child_toplevel_know_of` successfully', () => { expect(rolePermissionToBitmaskHex('child_toplevel_know_of')).toBe('00a'); }); it('should encode `child_opentoplevel_join_thread` successfully', () => { expect(rolePermissionToBitmaskHex('child_opentoplevel_join_thread')).toBe( '0ab', ); }); it('should encode `child_visible` successfully', () => { expect(rolePermissionToBitmaskHex('child_visible')).toBe('018'); }); it('should encode `child_know_of` successfully', () => { expect(rolePermissionToBitmaskHex('child_know_of')).toBe('008'); }); }); describe('decodeRolePermissionBitmask', () => { it('should decode `01b` to `child_opentoplevel_visible` successfully', () => { expect(decodeRolePermissionBitmask('01b')).toBe( 'child_opentoplevel_visible', ); }); it('should decode `00b` to `child_opentoplevel_know_of` successfully', () => { expect(decodeRolePermissionBitmask('00b')).toBe( 'child_opentoplevel_know_of', ); }); it('should decode `01a` to `child_toplevel_visible` successfully', () => { expect(decodeRolePermissionBitmask('01a')).toBe('child_toplevel_visible'); }); it('should decode `00a` to `child_toplevel_know_of` successfully', () => { expect(decodeRolePermissionBitmask('00a')).toBe('child_toplevel_know_of'); }); it('should decode `0ab` to `child_opentoplevel_join_thread` successfully', () => { expect(decodeRolePermissionBitmask('0ab')).toBe( 'child_opentoplevel_join_thread', ); }); it('should decode `018` to `child_visible` successfully', () => { expect(decodeRolePermissionBitmask('018')).toBe('child_visible'); }); it('should decode `008` to `child_know_of` successfully', () => { expect(decodeRolePermissionBitmask('008')).toBe('child_know_of'); }); }); const threadRolePermissionsBlob: ThreadRolePermissionsBlob = { add_members: true, child_open_join_thread: true, create_sidebars: true, create_subthreads: true, descendant_open_know_of: true, descendant_open_visible: true, descendant_opentoplevel_join_thread: true, edit_entries: true, edit_message: true, edit_permissions: true, edit_thread: true, edit_thread_avatar: true, edit_thread_color: true, edit_thread_description: true, know_of: true, leave_thread: true, react_to_message: true, remove_members: true, visible: true, voiced: true, open_know_of: true, open_visible: true, opentoplevel_join_thread: true, toplevel_know_of: true, toplevel_visible: true, opentoplevel_know_of: true, opentoplevel_visible: true, child_know_of: true, child_visible: true, child_opentoplevel_join_thread: true, child_toplevel_know_of: true, child_toplevel_visible: true, child_opentoplevel_know_of: true, child_opentoplevel_visible: true, }; const threadRolePermissionsBitmaskArray = [ '0c0', '0a9', '090', '080', '005', '015', '0a7', '030', '110', '0b0', '040', '120', '060', '050', '000', '0f0', '100', '0d0', '010', '020', '001', '011', '0a3', '002', '012', '003', '013', '008', '018', '0ab', '00a', '01a', '00b', '01b', ]; describe('threadRolePermissionsBlobToBitmaskArray', () => { it('should encode threadRolePermissionsBlob as bitmask array', () => { const arr = threadRolePermissionsBlobToBitmaskArray( threadRolePermissionsBlob, ); expect(arr).toEqual(threadRolePermissionsBitmaskArray); }); }); describe('decodeThreadRolePermissionsBitmaskArray', () => { it('should decode threadRolePermissionsBitmaskArray', () => { expect( decodeThreadRolePermissionsBitmaskArray( threadRolePermissionsBitmaskArray, ), ).toEqual(threadRolePermissionsBlob); }); }); describe('minimallyEncodedRoleInfoValidator', () => { it('should validate correctly formed MinimallyEncodedRoleInfo', () => { expect( roleInfoValidator.is({ minimallyEncoded: true, id: 'roleID', name: 'roleName', permissions: ['abc', 'def'], specialRole: specialRoles.DEFAULT_ROLE, }), ).toBe(true); }); it('should NOT validate malformed MinimallyEncodedRoleInfo', () => { expect( roleInfoValidator.is({ id: 1234, name: 'roleName', permissions: ['abc', 'def'], isDefault: true, specialRole: specialRoles.DEFAULT_ROLE, }), ).toBe(false); expect( roleInfoValidator.is({ id: 'roleID', name: 'roleName', permissions: ['hello a02 test', 'def'], isDefault: true, specialRole: specialRoles.DEFAULT_ROLE, }), ).toBe(false); expect( roleInfoValidator.is({ id: 'roleID', name: 'roleName', permissions: [123, 456], isDefault: true, specialRole: specialRoles.DEFAULT_ROLE, }), ).toBe(false); expect( roleInfoValidator.is({ id: 'roleID', name: 'roleName', permissions: ['ZZZ', 'YYY'], isDefault: true, specialRole: specialRoles.DEFAULT_ROLE, }), ).toBe(false); expect( roleInfoValidator.is({ id: 'roleID', name: 'roleName', permissions: ['AAAAA', 'YYY'], isDefault: true, specialRole: specialRoles.DEFAULT_ROLE, }), ).toBe(false); }); }); describe('persistedRoleInfoValidator', () => { it('should validate persisted RoleInfo with isDefault field', () => { expect( persistedRoleInfoValidator.is({ minimallyEncoded: true, id: 'roleID', name: 'roleName', permissions: ['abc', 'def'], specialRole: specialRoles.DEFAULT_ROLE, isDefault: true, }), ).toBe(true); }); }); describe('minimallyEncodedThreadCurrentUserInfoValidator', () => { it('should validate correctly formed MinimallyEncodedThreadCurrentUserInfo', () => { expect( threadCurrentUserInfoValidator.is({ minimallyEncoded: true, permissions: '100', subscription: { home: true, pushNotifs: true }, }), ).toBe(true); expect( threadCurrentUserInfoValidator.is({ minimallyEncoded: true, permissions: 'ABCDEFABCDEFABCD', subscription: { home: true, pushNotifs: true }, }), ).toBe(true); }); it('should NOT validate malformed MinimallyEncodedThreadCurrentUserInfo', () => { expect( threadCurrentUserInfoValidator.is({ minimallyEncoded: true, permissions: 'INVALID', subscription: { home: true, pushNotifs: true }, }), ).toBe(false); expect( threadCurrentUserInfoValidator.is({ minimallyEncoded: true, permissions: 'ABCDEF hello ABCDEFABCD', subscription: { home: true, pushNotifs: true }, }), ).toBe(false); expect( threadCurrentUserInfoValidator.is({ minimallyEncoded: true, permissions: 100, subscription: { home: true, pushNotifs: true }, }), ).toBe(false); }); }); describe('minimallyEncodedMemberInfoValidator', () => { it('should validate correctly formed MinimallyEncodedMemberInfo', () => { expect( memberInfoWithPermissionsValidator.is({ minimallyEncoded: true, id: 'memberID', permissions: 'ABCDEF', isSender: true, }), ).toBe(true); expect( memberInfoWithPermissionsValidator.is({ minimallyEncoded: true, id: 'memberID', permissions: '01b', isSender: false, }), ).toBe(true); }); it('should NOT validate malformed MinimallyEncodedMemberInfo', () => { expect( memberInfoWithPermissionsValidator.is({ minimallyEncoded: true, id: 'memberID', permissions: 'INVALID', isSender: false, }), ).toBe(false); expect( memberInfoWithPermissionsValidator.is({ minimallyEncoded: true, id: 'memberID', permissions: 100, isSender: false, }), ).toBe(false); }); }); describe('minimallyEncodedRawThreadInfoValidator', () => { it('should validate correctly formed MinimallyEncodedRawThreadInfo', () => { expect( rawThreadInfoValidator.is(exampleMinimallyEncodedRawThreadInfoA), ).toBe(true); }); }); describe('minimallyEncodeRawThreadInfo', () => { it('should correctly encode RawThreadInfo', () => { expect( rawThreadInfoValidator.is( - deprecatedMinimallyEncodeRawThreadInfo(exampleRawThreadInfoA), + minimallyEncodeRawThreadInfoWithMemberPermissions( + exampleRawThreadInfoA, + ), ), ).toBe(true); }); }); describe('decodeMinimallyEncodedRawThreadInfo', () => { it('should correctly decode minimallyEncodedRawThreadInfo', () => { expect( deprecatedDecodeMinimallyEncodedRawThreadInfo( - deprecatedMinimallyEncodeRawThreadInfo(exampleRawThreadInfoA), + minimallyEncodeRawThreadInfoWithMemberPermissions( + exampleRawThreadInfoA, + ), ), ).toStrictEqual(expectedDecodedExampleRawThreadInfoA); }); }); const threadCurrentUserInfo: LegacyThreadCurrentUserInfo = { role: '256|83795', permissions: { know_of: { value: true, source: '256|1', }, visible: { value: true, source: '256|1', }, voiced: { value: false, source: null, }, edit_entries: { value: false, source: null, }, edit_thread: { value: false, source: null, }, edit_thread_description: { value: false, source: null, }, edit_thread_color: { value: false, source: null, }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: false, 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: false, source: null, }, edit_message: { value: false, source: null, }, edit_thread_avatar: { value: false, source: null, }, manage_pins: { value: false, source: null, }, manage_invite_links: { 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: true, }; describe('minimallyEncodeThreadCurrentUserInfo', () => { it('should correctly encode threadCurrentUserInfo ONCE', () => { const minimallyEncoded = minimallyEncodeThreadCurrentUserInfo( threadCurrentUserInfo, ); expect(minimallyEncoded.permissions).toBe('3'); }); it('should throw when attempting to minimally encode threadCurrentUserInfo twice', () => { const minimallyEncoded = minimallyEncodeThreadCurrentUserInfo( threadCurrentUserInfo, ); expect(minimallyEncoded.permissions).toBe('3'); expect(() => // `MinimallyEncodedThreadCurrentUser` should never be passed // to `minimallyEncodeThreadCurrentUserInfo`. We're intentionally // bypassing Flow to simulate a scenario where malformed input is // passed to minimallyEncodeThreadCurrentUserInfo to ensure that the // `invariant` throws the expected error. // $FlowExpectedError minimallyEncodeThreadCurrentUserInfo(minimallyEncoded), ).toThrow('threadCurrentUserInfo is already minimally encoded.'); }); }); diff --git a/lib/shared/redux/deprecated-update-roles-and-permissions.js b/lib/shared/redux/deprecated-update-roles-and-permissions.js index e5fcee732..a2d704ecb 100644 --- a/lib/shared/redux/deprecated-update-roles-and-permissions.js +++ b/lib/shared/redux/deprecated-update-roles-and-permissions.js @@ -1,47 +1,47 @@ // @flow import { legacyUpdateRolesAndPermissions } from './legacy-update-roles-and-permissions.js'; import { deprecatedDecodeMinimallyEncodedRawThreadInfo, - deprecatedMinimallyEncodeRawThreadInfo, + minimallyEncodeRawThreadInfoWithMemberPermissions, type RawThreadInfo, } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { RawThreadInfos, LegacyRawThreadInfo, } from '../../types/thread-types.js'; function deprecatedUpdateRolesAndPermissions( threadStoreInfos: RawThreadInfos, ): RawThreadInfos { const decodedThreadStoreInfos: { [id: string]: LegacyRawThreadInfo } = {}; for (const threadID in threadStoreInfos) { const rawThreadInfo = threadStoreInfos[threadID]; const decodedThreadInfo = deprecatedDecodeMinimallyEncodedRawThreadInfo(rawThreadInfo); decodedThreadStoreInfos[threadID] = decodedThreadInfo; } const updatedDecodedThreadStoreInfos = legacyUpdateRolesAndPermissions( decodedThreadStoreInfos, ); const updatedThreadStoreInfos: { [id: string]: RawThreadInfo } = {}; for (const threadID in updatedDecodedThreadStoreInfos) { const updatedThreadInfo: LegacyRawThreadInfo = updatedDecodedThreadStoreInfos[threadID]; const encodedUpdatedThreadInfo = - deprecatedMinimallyEncodeRawThreadInfo(updatedThreadInfo); + minimallyEncodeRawThreadInfoWithMemberPermissions(updatedThreadInfo); updatedThreadStoreInfos[threadID] = encodedUpdatedThreadInfo; } return updatedThreadStoreInfos; } export { deprecatedUpdateRolesAndPermissions }; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index c3cc38939..7a1d14422 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1766 +1,1766 @@ // @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 { extractKeyserverIDFromIDOptional } 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, MinimallyEncodedThickMemberInfo, ThinRawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { decodeMinimallyEncodedRoleInfo, minimallyEncodeMemberInfo, - deprecatedMinimallyEncodeRawThreadInfo, + minimallyEncodeRawThreadInfoWithMemberPermissions, minimallyEncodeRoleInfo, minimallyEncodeThreadCurrentUserInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { defaultThreadSubscription } from '../types/subscription-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, threadTypeIsThick, assertThinThreadType, assertThickThreadType, } from '../types/thread-types-enum.js'; import type { LegacyRawThreadInfo, ClientLegacyRoleInfo, ServerThreadInfo, ThickMemberInfo, UserProfileThreadInfo, MixedRawThreadInfos, LegacyThinRawThreadInfo, } 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 { stripMemberPermissionsFromRawThreadInfo, type ThinRawThreadInfoWithPermissions, } from '../utils/member-info-utils.js'; import { entries, values } from '../utils/objects.js'; import { useSelector } from '../utils/redux-utils.js'; import { usingOlmViaTunnelbrokerForDMs } from '../utils/services-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 isMemberActive( memberInfo: MemberInfoWithPermissions | MinimallyEncodedThickMemberInfo, ): boolean { const role = memberInfo.role; return role !== null && 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); } type MemberIDAndRole = { +id: string, +role: ?string, ... }; function threadOtherMembers( 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 = extractKeyserverIDFromIDOptional(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, }; let rawThreadInfo: RawThreadInfo; if (threadTypeIsThick(threadType)) { const thickThreadType = assertThickThreadType(threadType); rawThreadInfo = { minimallyEncoded: true, thick: true, id: threadID, type: thickThreadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID( parentThreadInfo, thickThreadType, ), community: getCommunity(parentThreadInfo), members: members.map(member => minimallyEncodeMemberInfo({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, subscription: defaultThreadSubscription, }), ), roles: { [role.id]: role, }, currentUser: minimallyEncodeThreadCurrentUserInfo({ role: role.id, permissions: membershipPermissions, subscription: defaultThreadSubscription, unread: false, }), repliesCount: 0, sourceMessageID, pinnedCount: 0, }; } else { const thinThreadType = assertThinThreadType(threadType); rawThreadInfo = { minimallyEncoded: true, id: threadID, type: thinThreadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID( parentThreadInfo, thinThreadType, ), community: getCommunity(parentThreadInfo), members: members.map(member => ({ id: member.id, role: role.id, minimallyEncoded: true, isSender: false, })), roles: { [role.id]: role, }, currentUser: minimallyEncodeThreadCurrentUserInfo({ role: role.id, permissions: membershipPermissions, subscription: defaultThreadSubscription, 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.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 | 13 | 14 | 15 { if (usingOlmViaTunnelbrokerForDMs) { if (numberOfOtherMembers === 0) { return threadTypes.PRIVATE; } else if (numberOfOtherMembers === 1) { return threadTypes.PERSONAL; } else { return threadTypes.LOCAL; } } else { if (numberOfOtherMembers === 0) { return threadTypes.GENESIS_PRIVATE; } else if (numberOfOtherMembers === 1) { return threadTypes.GENESIS_PERSONAL; } else { return threadTypes.COMMUNITY_SECRET_SUBTHREAD; } } } function threadTypeCanBePending(threadType: ThreadType): boolean { return ( threadType === threadTypes.GENESIS_PERSONAL || threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.SIDEBAR || threadType === threadTypes.GENESIS_PRIVATE || threadType === threadTypes.PERSONAL || threadType === threadTypes.LOCAL || threadType === threadTypes.THICK_SIDEBAR || threadType === threadTypes.PRIVATE ); } type RawThreadInfoOptions = { +filterThreadEditAvatarPermission?: boolean, +excludePinInfo?: boolean, +filterManageInviteLinksPermission?: boolean, +filterVoicedInAnnouncementChannelsPermission?: boolean, +minimallyEncodePermissions?: boolean, +includeSpecialRoleFieldInRoles?: boolean, +allowAddingUsersToCommunityRoot?: boolean, +filterManageFarcasterChannelTagsPermission?: boolean, +stripMemberPermissions?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?LegacyThinRawThreadInfo | ?ThinRawThreadInfo { 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 stripMemberPermissions = options?.stripMemberPermissions; 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: defaultThreadSubscription, 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 minimallyEncodedRawThreadInfoWithMemberPermissions = - deprecatedMinimallyEncodeRawThreadInfo(rawThreadInfo); + minimallyEncodeRawThreadInfoWithMemberPermissions(rawThreadInfo); invariant( !minimallyEncodedRawThreadInfoWithMemberPermissions.thick, 'ServerThreadInfo should be thin thread', ); if (!shouldIncludeSpecialRoleFieldInRoles) { const minimallyEncodedRolesWithoutSpecialRoleField = Object.fromEntries( entries(minimallyEncodedRawThreadInfoWithMemberPermissions.roles).map( ([key, role]) => [ key, { ..._omit('specialRole')(role), isDefault: roleIsDefaultRole(role), }, ], ), ); return { ...minimallyEncodedRawThreadInfoWithMemberPermissions, roles: minimallyEncodedRolesWithoutSpecialRoleField, }; } if (!stripMemberPermissions) { return minimallyEncodedRawThreadInfoWithMemberPermissions; } // The return value of `deprecatedMinimallyEncodeRawThreadInfo` is typed // as `RawThreadInfo`, but still includes thread member permissions. // This was to prevent introducing "Legacy" types that would need to be // maintained going forward. This `any`-cast allows us to more precisely // type the obj being passed to `stripMemberPermissionsFromRawThreadInfo`. const rawThreadInfoWithMemberPermissions: ThinRawThreadInfoWithPermissions = (minimallyEncodedRawThreadInfoWithMemberPermissions: any); return stripMemberPermissionsFromRawThreadInfo( rawThreadInfoWithMemberPermissions, ); } 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.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.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 threadIsWithBlockedUserOnlyWithoutAdminRoleCheck( threadInfo: ThreadInfo | RawThreadInfo | LegacyRawThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock: boolean, ): boolean { if (threadOrParentThreadIsGroupChat(threadInfo)) { return false; } return baseThreadIsWithBlockedUserOnly( threadInfo, viewerID, userInfos, checkOnlyViewerBlock, ); } 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.', }; 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 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 = `Muted 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 “Muted” 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.GENESIS_PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { return 'Thread'; } 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.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.GENESIS_PRIVATE) { privateThreads.push({ ...item, sidebars: [] }); } 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.GENESIS_PRIVATE) { privateThreads.push(threadInfo); } 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.GENESIS_PRIVATE, ); const privateThreadInfos = useSelector(privateThreadInfosSelector); const personalThreadInfosSelector = threadInfosSelectorForThreadType( 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, useThreadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, threadUIName, threadInfoFromRawThreadInfo, threadTypeDescriptions, threadIsWithBlockedUserOnlyWithoutAdminRoleCheck, 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, isMemberActive, }; diff --git a/lib/types/minimally-encoded-thread-permissions-types.js b/lib/types/minimally-encoded-thread-permissions-types.js index 09359816b..cec45648f 100644 --- a/lib/types/minimally-encoded-thread-permissions-types.js +++ b/lib/types/minimally-encoded-thread-permissions-types.js @@ -1,294 +1,294 @@ // @flow import invariant from 'invariant'; import _mapValues from 'lodash/fp/mapValues.js'; import type { ClientAvatar } from './avatar-types.js'; import type { ThreadPermissionsInfo } from './thread-permission-types.js'; import type { ThreadType } from './thread-types-enum.js'; import type { ClientLegacyRoleInfo, LegacyMemberInfo, LegacyRawThreadInfo, LegacyThickRawThreadInfo, LegacyThinRawThreadInfo, LegacyThreadCurrentUserInfo, ThickMemberInfo, } from './thread-types.js'; import { decodeThreadRolePermissionsBitmaskArray, permissionsToBitmaskHex, threadPermissionsFromBitmaskHex, threadRolePermissionsBlobToBitmaskArray, } from '../permissions/minimally-encoded-thread-permissions.js'; import type { SpecialRole } from '../permissions/special-roles.js'; import { specialRoles } from '../permissions/special-roles.js'; import { roleIsAdminRole, roleIsDefaultRole } from '../shared/thread-utils.js'; import type { ThreadEntity } from '../utils/entity-text.js'; type RoleInfoBase = $ReadOnly<{ +id: string, +name: string, +minimallyEncoded: true, +permissions: $ReadOnlyArray, }>; export type RoleInfo = $ReadOnly<{ ...RoleInfoBase, +specialRole?: ?SpecialRole, }>; const minimallyEncodeRoleInfo = (roleInfo: ClientLegacyRoleInfo): RoleInfo => { invariant( !('minimallyEncoded' in roleInfo), 'roleInfo is already minimally encoded.', ); let specialRole: ?SpecialRole; if (roleIsDefaultRole(roleInfo)) { specialRole = specialRoles.DEFAULT_ROLE; } else if (roleIsAdminRole(roleInfo)) { specialRole = specialRoles.ADMIN_ROLE; } const { isDefault, ...rest } = roleInfo; return { ...rest, minimallyEncoded: true, permissions: threadRolePermissionsBlobToBitmaskArray(roleInfo.permissions), specialRole, }; }; const decodeMinimallyEncodedRoleInfo = ( minimallyEncodedRoleInfo: RoleInfo, ): ClientLegacyRoleInfo => { const { minimallyEncoded, specialRole, ...rest } = minimallyEncodedRoleInfo; return { ...rest, permissions: decodeThreadRolePermissionsBitmaskArray( minimallyEncodedRoleInfo.permissions, ), isDefault: roleIsDefaultRole(minimallyEncodedRoleInfo), }; }; export type ThreadCurrentUserInfo = $ReadOnly<{ ...LegacyThreadCurrentUserInfo, +minimallyEncoded: true, +permissions: string, }>; const minimallyEncodeThreadCurrentUserInfo = ( threadCurrentUserInfo: LegacyThreadCurrentUserInfo, ): ThreadCurrentUserInfo => { invariant( !('minimallyEncoded' in threadCurrentUserInfo), 'threadCurrentUserInfo is already minimally encoded.', ); return { ...threadCurrentUserInfo, minimallyEncoded: true, permissions: permissionsToBitmaskHex(threadCurrentUserInfo.permissions), }; }; const decodeMinimallyEncodedThreadCurrentUserInfo = ( minimallyEncodedThreadCurrentUserInfo: ThreadCurrentUserInfo, ): LegacyThreadCurrentUserInfo => { const { minimallyEncoded, ...rest } = minimallyEncodedThreadCurrentUserInfo; return { ...rest, permissions: threadPermissionsFromBitmaskHex( minimallyEncodedThreadCurrentUserInfo.permissions, ), }; }; export type MemberInfoWithPermissions = $ReadOnly<{ ...LegacyMemberInfo, +minimallyEncoded: true, +permissions: string, }>; export type MemberInfoSansPermissions = $Diff< MemberInfoWithPermissions, { +permissions: string }, >; export type MinimallyEncodedThickMemberInfo = $ReadOnly<{ ...ThickMemberInfo, +minimallyEncoded: true, +permissions: string, }>; const minimallyEncodeMemberInfo = ( memberInfo: T, ): $ReadOnly<{ ...T, +minimallyEncoded: true, +permissions: string, }> => { invariant( !('minimallyEncoded' in memberInfo), 'memberInfo is already minimally encoded.', ); return { ...memberInfo, minimallyEncoded: true, permissions: permissionsToBitmaskHex(memberInfo.permissions), }; }; const decodeMinimallyEncodedMemberInfo = < T: MemberInfoWithPermissions | MinimallyEncodedThickMemberInfo, >( minimallyEncodedMemberInfo: T, ): $ReadOnly<{ ...$Diff< T, { +minimallyEncoded: true, +permissions: string, }, >, +permissions: ThreadPermissionsInfo, }> => { const { minimallyEncoded, ...rest } = minimallyEncodedMemberInfo; return { ...rest, permissions: threadPermissionsFromBitmaskHex( minimallyEncodedMemberInfo.permissions, ), }; }; export type ThinRawThreadInfo = $ReadOnly<{ ...LegacyThinRawThreadInfo, +minimallyEncoded: true, +members: $ReadOnlyArray, +roles: { +[id: string]: RoleInfo }, +currentUser: ThreadCurrentUserInfo, }>; export type ThickRawThreadInfo = $ReadOnly<{ ...LegacyThickRawThreadInfo, +minimallyEncoded: true, +members: $ReadOnlyArray, +roles: { +[id: string]: RoleInfo }, +currentUser: ThreadCurrentUserInfo, }>; export type RawThreadInfo = ThinRawThreadInfo | ThickRawThreadInfo; -const deprecatedMinimallyEncodeRawThreadInfo = ( +const minimallyEncodeRawThreadInfoWithMemberPermissions = ( rawThreadInfo: LegacyRawThreadInfo, ): RawThreadInfo => { invariant( !('minimallyEncoded' in rawThreadInfo), 'rawThreadInfo is already minimally encoded.', ); if (rawThreadInfo.thick) { const { members, roles, currentUser, ...rest } = rawThreadInfo; return { ...rest, minimallyEncoded: true, members: members.map(minimallyEncodeMemberInfo), roles: _mapValues(minimallyEncodeRoleInfo)(roles), currentUser: minimallyEncodeThreadCurrentUserInfo(currentUser), }; } else { const { members, roles, currentUser, ...rest } = rawThreadInfo; // We removed the `.permissions` field from `MemberInfo`, but persisted // `MemberInfo`s will still have the field in legacy migrations. // $FlowIgnore return { ...rest, minimallyEncoded: true, members: members.map(minimallyEncodeMemberInfo), roles: _mapValues(minimallyEncodeRoleInfo)(roles), currentUser: minimallyEncodeThreadCurrentUserInfo(currentUser), }; } }; const deprecatedDecodeMinimallyEncodedRawThreadInfo = ( minimallyEncodedRawThreadInfo: RawThreadInfo, ): LegacyRawThreadInfo => { if (minimallyEncodedRawThreadInfo.thick) { const { minimallyEncoded, members, roles, currentUser, ...rest } = minimallyEncodedRawThreadInfo; return { ...rest, members: members.map(decodeMinimallyEncodedMemberInfo), roles: _mapValues(decodeMinimallyEncodedRoleInfo)(roles), currentUser: decodeMinimallyEncodedThreadCurrentUserInfo(currentUser), }; } else { const { minimallyEncoded, members, roles, currentUser, ...rest } = minimallyEncodedRawThreadInfo; return { ...rest, // We removed the `.permissions` field from `MemberInfo`, but persisted // `MemberInfo`s will still have the field in legacy migrations. // $FlowIgnore members: members.map(decodeMinimallyEncodedMemberInfo), roles: _mapValues(decodeMinimallyEncodedRoleInfo)(roles), currentUser: decodeMinimallyEncodedThreadCurrentUserInfo(currentUser), }; } }; export type RoleInfoWithoutSpecialRole = $ReadOnly<{ ...RoleInfoBase, +isDefault?: boolean, }>; export type RawThreadInfoWithoutSpecialRole = $ReadOnly<{ ...RawThreadInfo, +roles: { +[id: string]: RoleInfoWithoutSpecialRole }, }>; export type RelativeMemberInfo = { +id: string, +role: ?string, +isSender: boolean, +minimallyEncoded: true, +username: ?string, +isViewer: boolean, }; export type ThreadInfo = $ReadOnly<{ +minimallyEncoded: true, +id: string, +type: ThreadType, +name: ?string, +uiName: string | ThreadEntity, +avatar?: ?ClientAvatar, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: RoleInfo }, +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }>; export type ResolvedThreadInfo = $ReadOnly<{ ...ThreadInfo, +uiName: string, }>; export { minimallyEncodeRoleInfo, decodeMinimallyEncodedRoleInfo, minimallyEncodeThreadCurrentUserInfo, decodeMinimallyEncodedThreadCurrentUserInfo, minimallyEncodeMemberInfo, decodeMinimallyEncodedMemberInfo, - deprecatedMinimallyEncodeRawThreadInfo, + minimallyEncodeRawThreadInfoWithMemberPermissions, deprecatedDecodeMinimallyEncodedRawThreadInfo, }; diff --git a/native/redux/persist.js b/native/redux/persist.js index a43f7251f..3c06bd1aa 100644 --- a/native/redux/persist.js +++ b/native/redux/persist.js @@ -1,1461 +1,1461 @@ // @flow import AsyncStorage from '@react-native-async-storage/async-storage'; import invariant from 'invariant'; import { Platform } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { createTransform } from 'redux-persist'; import type { Transform, Persistor } from 'redux-persist/es/types.js'; import { convertEntryStoreToNewIDSchema, convertInviteLinksStoreToNewIDSchema, convertMessageStoreToNewIDSchema, convertRawMessageInfoToNewIDSchema, convertCalendarFilterToNewIDSchema, convertConnectionInfoToNewIDSchema, } from 'lib/_generated/migration-utils.js'; import { extractKeyserverIDFromID } from 'lib/keyserver-conn/keyserver-call-utils.js'; import type { ClientDBEntryStoreOperation, ReplaceEntryOperation, } from 'lib/ops/entries-store-ops'; import { entryStoreOpsHandlers } from 'lib/ops/entries-store-ops.js'; import { type ClientDBIntegrityStoreOperation, integrityStoreOpsHandlers, type ReplaceIntegrityThreadHashesOperation, } from 'lib/ops/integrity-store-ops.js'; import { type ClientDBKeyserverStoreOperation, keyserverStoreOpsHandlers, type ReplaceKeyserverOperation, } from 'lib/ops/keyserver-store-ops.js'; import { type ClientDBMessageStoreOperation, type ReplaceMessageStoreLocalMessageInfoOperation, type MessageStoreOperation, messageStoreOpsHandlers, } from 'lib/ops/message-store-ops.js'; import { type ReportStoreOperation, type ClientDBReportStoreOperation, convertReportsToReplaceReportOps, reportStoreOpsHandlers, } from 'lib/ops/report-store-ops.js'; import { type ClientDBThreadActivityStoreOperation, threadActivityStoreOpsHandlers, type ReplaceThreadActivityEntryOperation, } from 'lib/ops/thread-activity-store-ops.js'; import type { ClientDBThreadStoreOperation } from 'lib/ops/thread-store-ops.js'; import { threadStoreOpsHandlers } from 'lib/ops/thread-store-ops.js'; import { type ClientDBUserStoreOperation, type UserStoreOperation, convertUserInfosToReplaceUserOps, userStoreOpsHandlers, } from 'lib/ops/user-store-ops.js'; import { patchRawThreadInfosWithSpecialRole } from 'lib/permissions/special-roles.js'; import { filterThreadIDsInFilterList } from 'lib/reducers/calendar-filters-reducer.js'; import { highestLocalIDSelector } from 'lib/selectors/local-id-selectors.js'; import { createUpdateDBOpsForThreadStoreThreadInfos } from 'lib/shared/redux/client-db-utils.js'; import { deprecatedUpdateRolesAndPermissions } from 'lib/shared/redux/deprecated-update-roles-and-permissions.js'; import { legacyUpdateRolesAndPermissions } from 'lib/shared/redux/legacy-update-roles-and-permissions.js'; import { inconsistencyResponsesToReports } from 'lib/shared/report-utils.js'; import { getContainingThreadID, getCommunity, assertAllThreadInfosAreLegacy, } from 'lib/shared/thread-utils.js'; import { keyserverStoreTransform } from 'lib/shared/transforms/keyserver-store-transform.js'; import { messageStoreMessagesBlocklistTransform } from 'lib/shared/transforms/message-store-transform.js'; import { DEPRECATED_unshimMessageStore, unshimFunc, } from 'lib/shared/unshim-utils.js'; import { defaultAlertInfo, defaultAlertInfos, alertTypes, } from 'lib/types/alert-types.js'; import { defaultEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarQuery } from 'lib/types/entry-types.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import type { KeyserverInfo } from 'lib/types/keyserver-types.js'; import { messageTypes, type MessageType, } from 'lib/types/message-types-enum.js'; import { type MessageStoreThreads, type RawMessageInfo, } from 'lib/types/message-types.js'; -import { deprecatedMinimallyEncodeRawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; +import { minimallyEncodeRawThreadInfoWithMemberPermissions } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { RawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ReportStore, ClientReportCreationRequest, } from 'lib/types/report-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import { defaultGlobalThemeInfo } from 'lib/types/theme-types.js'; import type { ClientDBThreadInfo, LegacyRawThreadInfo, MixedRawThreadInfos, } from 'lib/types/thread-types.js'; import { translateClientDBMessageInfoToRawMessageInfo, translateRawMessageInfoToClientDBMessageInfo, } from 'lib/utils/message-ops-utils.js'; import { generateIDSchemaMigrationOpsForDrafts, convertMessageStoreThreadsToNewIDSchema, convertThreadStoreThreadInfosToNewIDSchema, createAsyncMigrate, } from 'lib/utils/migration-utils.js'; import { entries } from 'lib/utils/objects.js'; import { deprecatedConvertClientDBThreadInfoToRawThreadInfo, convertRawThreadInfoToClientDBThreadInfo, } from 'lib/utils/thread-ops-utils.js'; import { getUUID } from 'lib/utils/uuid.js'; import { createUpdateDBOpsForMessageStoreMessages, createUpdateDBOpsForMessageStoreThreads, updateClientDBThreadStoreThreadInfos, } from './client-db-utils.js'; import { defaultState } from './default-state.js'; import { deprecatedCreateUpdateDBOpsForThreadStoreThreadInfos, deprecatedUpdateClientDBThreadStoreThreadInfos, } from './deprecated-client-db-utils.js'; import { migrateThreadStoreForEditThreadPermissions } from './edit-thread-permission-migration.js'; import { handleReduxMigrationFailure, persistBlacklist, } from './handle-redux-migration-failure.js'; import { persistMigrationForManagePinsThreadPermission } from './manage-pins-permission-migration.js'; import { persistMigrationToRemoveSelectRolePermissions } from './remove-select-role-permissions.js'; import type { AppState } from './state-types.js'; import { unshimClientDB } from './unshim-utils.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { commCoreModule } from '../native-modules.js'; import { defaultDeviceCameraInfo } from '../types/camera.js'; import { isTaskCancelledError } from '../utils/error-handling.js'; import { defaultURLPrefix } from '../utils/url-utils.js'; const legacyMigrations = { [1]: (state: AppState) => ({ ...state, notifPermissionAlertInfo: defaultAlertInfo, }), [2]: (state: AppState) => ({ ...state, messageSentFromRoute: [], }), [3]: (state: any) => ({ currentUserInfo: state.currentUserInfo, entryStore: state.entryStore, threadInfos: state.threadInfos, userInfos: state.userInfos, messageStore: { ...state.messageStore, currentAsOf: state.currentAsOf, }, updatesCurrentAsOf: state.currentAsOf, cookie: state.cookie, deviceToken: state.deviceToken, urlPrefix: state.urlPrefix, customServer: state.customServer, notifPermissionAlertInfo: state.notifPermissionAlertInfo, messageSentFromRoute: state.messageSentFromRoute, _persist: state._persist, }), [4]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, }), [5]: (state: AppState) => ({ ...state, calendarFilters: defaultCalendarFilters, }), [6]: (state: any) => ({ ...state, threadInfos: undefined, threadStore: { threadInfos: state.threadInfos, inconsistencyResponses: [], }, }), [7]: (state: AppState) => ({ ...state, lastUserInteraction: undefined, sessionID: undefined, entryStore: { ...state.entryStore, inconsistencyResponses: [], }, }), [8]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, connection: { ...defaultConnectionInfo, actualizedCalendarQuery: defaultCalendarQuery(Platform.OS), }, watchedThreadIDs: [], entryStore: { ...state.entryStore, actualizedCalendarQuery: undefined, }, }), [9]: (state: any) => ({ ...state, connection: { ...state.connection, lateResponses: [], }, }), [10]: (state: any) => ({ ...state, nextLocalID: highestLocalIDSelector(state) + 1, connection: { ...state.connection, showDisconnectedBar: false, }, messageStore: { ...state.messageStore, local: {}, }, }), [11]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.IMAGES, ]), }), [12]: (state: AppState) => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [13]: (state: AppState) => ({ ...state, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), }), [14]: (state: AppState) => state, [15]: (state: any) => ({ ...state, threadStore: { ...state.threadStore, inconsistencyReports: inconsistencyResponsesToReports( state.threadStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: inconsistencyResponsesToReports( state.entryStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, queuedReports: [], }), [16]: (state: any) => { const result = { ...state, messageSentFromRoute: undefined, dataLoaded: !!state.currentUserInfo && !state.currentUserInfo.anonymous, }; if (state.navInfo) { result.navInfo = { ...state.navInfo, navigationState: undefined, }; } return result; }, [17]: (state: any) => ({ ...state, userInfos: undefined, userStore: { userInfos: state.userInfos, inconsistencyResponses: [], }, }), [18]: (state: AppState) => ({ ...state, userStore: { userInfos: state.userStore.userInfos, inconsistencyReports: [], }, }), [19]: (state: any) => { const threadInfos: { [string]: LegacyRawThreadInfo } = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const { visibilityRules, ...rest } = threadInfo; threadInfos[threadID] = rest; } return { ...state, threadStore: { ...state.threadStore, threadInfos, }, }; }, [20]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.LEGACY_UPDATE_RELATIONSHIP, ]), }), [21]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.CREATE_SIDEBAR, messageTypes.SIDEBAR_SOURCE, ]), }), [22]: (state: any) => { for (const key in state.drafts) { const value = state.drafts[key]; try { void commCoreModule.updateDraft(key, value); } catch (e) { if (!isTaskCancelledError(e)) { throw e; } } } return { ...state, drafts: undefined, }; }, [23]: (state: AppState) => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [24]: (state: AppState) => ({ ...state, enabledApps: defaultEnabledApps, }), [25]: (state: AppState) => ({ ...state, crashReportsEnabled: __DEV__, }), [26]: (state: any) => { const { currentUserInfo } = state; if (currentUserInfo.anonymous) { return state; } return { ...state, crashReportsEnabled: undefined, currentUserInfo: { id: currentUserInfo.id, username: currentUserInfo.username, }, enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, }; }, [27]: (state: any) => ({ ...state, queuedReports: undefined, enabledReports: undefined, threadStore: { ...state.threadStore, inconsistencyReports: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: undefined, }, reportStore: { enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, queuedReports: [ ...state.entryStore.inconsistencyReports, ...state.threadStore.inconsistencyReports, ...state.queuedReports, ], }, }), [28]: (state: AppState) => { const threadParentToChildren: { [string]: string[] } = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? state.threadStore.threadInfos[threadInfo.parentThreadID] : null; const parentIndex = parentThreadInfo ? parentThreadInfo.id : '-1'; if (!threadParentToChildren[parentIndex]) { threadParentToChildren[parentIndex] = []; } threadParentToChildren[parentIndex].push(threadID); } const rootIDs = threadParentToChildren['-1']; if (!rootIDs) { // This should never happen, but if it somehow does we'll let the state // check mechanism resolve it... return state; } const threadInfos: { [string]: LegacyRawThreadInfo | RawThreadInfo, } = {}; const stack = [...rootIDs]; while (stack.length > 0) { const threadID = stack.shift(); const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; threadInfos[threadID] = { ...threadInfo, containingThreadID: getContainingThreadID( parentThreadInfo, threadInfo.type, ), community: getCommunity(parentThreadInfo), }; const children = threadParentToChildren[threadID]; if (children) { stack.push(...children); } } return { ...state, threadStore: { ...state.threadStore, threadInfos } }; }, [29]: (state: AppState) => { const legacyRawThreadInfos: { +[id: string]: LegacyRawThreadInfo, } = assertAllThreadInfosAreLegacy(state.threadStore.threadInfos); const updatedThreadInfos = migrateThreadStoreForEditThreadPermissions(legacyRawThreadInfos); return { ...state, threadStore: { ...state.threadStore, threadInfos: updatedThreadInfos, }, }; }, [30]: (state: AppState) => { const threadInfos = state.threadStore.threadInfos; const operations = [ { type: 'remove_all', }, ...Object.keys(threadInfos).map((id: string) => ({ type: 'replace', payload: { id, threadInfo: threadInfos[id] }, })), ]; try { commCoreModule.processThreadStoreOperationsSync( threadStoreOpsHandlers.convertOpsToClientDBOps(operations), ); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [31]: (state: AppState) => { const messages = state.messageStore.messages; const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...Object.keys(messages).map((id: string) => ({ type: 'replace', payload: translateRawMessageInfoToClientDBMessageInfo(messages[id]), })), ]; try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [32]: (state: AppState) => unshimClientDB(state, [messageTypes.MULTIMEDIA]), [33]: (state: AppState) => unshimClientDB(state, [messageTypes.REACTION]), [34]: (state: any) => { const { threadIDsToNotifIDs, ...stateSansThreadIDsToNotifIDs } = state; return stateSansThreadIDsToNotifIDs; }, [35]: (state: AppState) => unshimClientDB(state, [messageTypes.MULTIMEDIA]), [36]: (state: AppState) => { // 1. Get threads and messages from SQLite `threads` and `messages` tables. const clientDBThreadInfos = commCoreModule.getAllThreadsSync(); const clientDBMessageInfos = commCoreModule.getAllMessagesSync(); // 2. Translate `ClientDBThreadInfo`s to `RawThreadInfo`s and // `ClientDBMessageInfo`s to `RawMessageInfo`s. const rawThreadInfos = clientDBThreadInfos.map( deprecatedConvertClientDBThreadInfoToRawThreadInfo, ); const rawMessageInfos = clientDBMessageInfos.map( translateClientDBMessageInfoToRawMessageInfo, ); // 3. Unshim translated `RawMessageInfos` to get the TOGGLE_PIN messages const unshimmedRawMessageInfos = rawMessageInfos.map(messageInfo => unshimFunc(messageInfo, new Set([messageTypes.TOGGLE_PIN])), ); // 4. Filter out non-TOGGLE_PIN messages const filteredRawMessageInfos = unshimmedRawMessageInfos .map(messageInfo => messageInfo.type === messageTypes.TOGGLE_PIN ? messageInfo : null, ) .filter(Boolean); // 5. We want only the last TOGGLE_PIN message for each message ID, // so 'pin', 'unpin', 'pin' don't count as 3 pins, but only 1. const lastMessageIDToRawMessageInfoMap = new Map(); for (const messageInfo of filteredRawMessageInfos) { const { targetMessageID } = messageInfo; lastMessageIDToRawMessageInfoMap.set(targetMessageID, messageInfo); } const lastMessageIDToRawMessageInfos = Array.from( lastMessageIDToRawMessageInfoMap.values(), ); // 6. Create a Map of threadIDs to pinnedCount const threadIDsToPinnedCount = new Map(); for (const messageInfo of lastMessageIDToRawMessageInfos) { const { threadID, type } = messageInfo; if (type === messageTypes.TOGGLE_PIN) { const pinnedCount = threadIDsToPinnedCount.get(threadID) || 0; threadIDsToPinnedCount.set(threadID, pinnedCount + 1); } } // 7. Include a pinnedCount for each rawThreadInfo const rawThreadInfosWithPinnedCount = rawThreadInfos.map(threadInfo => ({ ...threadInfo, pinnedCount: threadIDsToPinnedCount.get(threadInfo.id) || 0, })); // 8. Convert rawThreadInfos to a map of threadID to threadInfo const threadIDToThreadInfo = rawThreadInfosWithPinnedCount.reduce( ( acc: { [string]: LegacyRawThreadInfo }, threadInfo: LegacyRawThreadInfo, ) => { acc[threadInfo.id] = threadInfo; return acc; }, {}, ); // 9. Add threadPermission to each threadInfo const rawThreadInfosWithThreadPermission = persistMigrationForManagePinsThreadPermission(threadIDToThreadInfo); // 10. Convert the new threadInfos back into an array const rawThreadInfosWithCountAndPermission = Object.keys( rawThreadInfosWithThreadPermission, ).map(id => rawThreadInfosWithThreadPermission[id]); // 11. Translate `RawThreadInfo`s to `ClientDBThreadInfo`s. const convertedClientDBThreadInfos = rawThreadInfosWithCountAndPermission.map( convertRawThreadInfoToClientDBThreadInfo, ); // 12. Construct `ClientDBThreadStoreOperation`s to clear SQLite `threads` // table and repopulate with `ClientDBThreadInfo`s. const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...convertedClientDBThreadInfos.map((thread: ClientDBThreadInfo) => ({ type: 'replace', payload: thread, })), ]; // 13. Try processing `ClientDBThreadStoreOperation`s and log out if // `processThreadStoreOperationsSync(...)` throws an exception. try { commCoreModule.processThreadStoreOperationsSync(operations); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } return state; }, [37]: (state: AppState) => { const operations = messageStoreOpsHandlers.convertOpsToClientDBOps([ { type: 'remove_all_threads', }, { type: 'replace_threads', payload: { threads: state.messageStore.threads }, }, ]); try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.error(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [38]: (state: AppState) => deprecatedUpdateClientDBThreadStoreThreadInfos( state, legacyUpdateRolesAndPermissions, ), [39]: (state: AppState) => unshimClientDB(state, [messageTypes.EDIT_MESSAGE]), [40]: (state: AppState) => deprecatedUpdateClientDBThreadStoreThreadInfos( state, legacyUpdateRolesAndPermissions, ), [41]: (state: AppState) => { const queuedReports = state.reportStore.queuedReports.map(report => ({ ...report, id: getUUID(), })); return { ...state, reportStore: { ...state.reportStore, queuedReports }, }; }, [42]: (state: AppState) => { const reportStoreOperations: $ReadOnlyArray = [ { type: 'remove_all_reports' }, ...convertReportsToReplaceReportOps(state.reportStore.queuedReports), ]; const dbOperations: $ReadOnlyArray = reportStoreOpsHandlers.convertOpsToClientDBOps(reportStoreOperations); try { commCoreModule.processReportStoreOperationsSync(dbOperations); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [43]: async (state: any) => { const { messages, drafts, threads, messageStoreThreads } = await commCoreModule.getClientDBStore(); const messageStoreThreadsOperations = createUpdateDBOpsForMessageStoreThreads( messageStoreThreads, convertMessageStoreThreadsToNewIDSchema, ); const messageStoreMessagesOperations = createUpdateDBOpsForMessageStoreMessages(messages, messageInfos => messageInfos.map(convertRawMessageInfoToNewIDSchema), ); const threadOperations = deprecatedCreateUpdateDBOpsForThreadStoreThreadInfos( threads, convertThreadStoreThreadInfosToNewIDSchema, ); const draftOperations = generateIDSchemaMigrationOpsForDrafts(drafts); try { await commCoreModule.processDBStoreOperations({ messageStoreOperations: [ ...messageStoreMessagesOperations, ...messageStoreThreadsOperations, ], threadStoreOperations: threadOperations, draftStoreOperations: draftOperations, }); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } const inviteLinksStore = state.inviteLinksStore ?? defaultState.inviteLinksStore; return { ...state, entryStore: convertEntryStoreToNewIDSchema(state.entryStore), messageStore: convertMessageStoreToNewIDSchema(state.messageStore), calendarFilters: state.calendarFilters.map( convertCalendarFilterToNewIDSchema, ), connection: convertConnectionInfoToNewIDSchema(state.connection), watchedThreadIDs: state.watchedThreadIDs.map( id => `${authoritativeKeyserverID}|${id}`, ), inviteLinksStore: convertInviteLinksStoreToNewIDSchema(inviteLinksStore), }; }, [44]: async (state: any) => { const { cookie, ...rest } = state; return { ...rest, keyserverStore: { keyserverInfos: { [authoritativeKeyserverID]: { cookie } }, }, }; }, [45]: async (state: any) => { const { updatesCurrentAsOf, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [authoritativeKeyserverID]: { ...keyserverStore.keyserverInfos[authoritativeKeyserverID], updatesCurrentAsOf, }, }, }, }; }, [46]: async (state: AppState) => { const { currentAsOf } = state.messageStore; return { ...state, messageStore: { ...state.messageStore, currentAsOf: { [authoritativeKeyserverID]: currentAsOf }, }, }; }, [47]: async (state: any) => { const { urlPrefix, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [authoritativeKeyserverID]: { ...keyserverStore.keyserverInfos[authoritativeKeyserverID], urlPrefix, }, }, }, }; }, [48]: async (state: any) => { const { connection, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [authoritativeKeyserverID]: { ...keyserverStore.keyserverInfos[authoritativeKeyserverID], connection, }, }, }, }; }, [49]: async (state: AppState) => { const { keyserverStore, ...rest } = state; const { connection, ...keyserverRest } = keyserverStore.keyserverInfos[authoritativeKeyserverID]; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [authoritativeKeyserverID]: { ...keyserverRest, }, }, }, connection, }; }, [50]: async (state: any) => { const { connection, ...rest } = state; const { actualizedCalendarQuery, ...connectionRest } = connection; return { ...rest, connection: connectionRest, actualizedCalendarQuery, }; }, [51]: async (state: any) => { const { lastCommunicatedPlatformDetails, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [authoritativeKeyserverID]: { ...keyserverStore.keyserverInfos[authoritativeKeyserverID], lastCommunicatedPlatformDetails, }, }, }, }; }, [52]: async (state: AppState) => ({ ...state, integrityStore: { threadHashes: {}, threadHashingStatus: 'data_not_loaded', }, }), [53]: (state: any) => { if (!state.userStore.inconsistencyReports) { return state; } const reportStoreOperations = convertReportsToReplaceReportOps( state.userStore.inconsistencyReports, ); const dbOperations: $ReadOnlyArray = reportStoreOpsHandlers.convertOpsToClientDBOps(reportStoreOperations); try { commCoreModule.processReportStoreOperationsSync(dbOperations); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return handleReduxMigrationFailure(state); } const { inconsistencyReports, ...newUserStore } = state.userStore; const queuedReports = reportStoreOpsHandlers.processStoreOperations( state.reportStore.queuedReports, reportStoreOperations, ); return { ...state, userStore: newUserStore, reportStore: { ...state.reportStore, queuedReports, }, }; }, [54]: (state: any) => { let updatedMessageStoreThreads: MessageStoreThreads = {}; for (const threadID: string in state.messageStore.threads) { const { lastNavigatedTo, lastPruned, ...rest } = state.messageStore.threads[threadID]; updatedMessageStoreThreads = { ...updatedMessageStoreThreads, [threadID]: rest, }; } return { ...state, messageStore: { ...state.messageStore, threads: updatedMessageStoreThreads, }, }; }, [55]: async (state: AppState) => __DEV__ ? { ...state, keyserverStore: { ...state.keyserverStore, keyserverInfos: { ...state.keyserverStore.keyserverInfos, [authoritativeKeyserverID]: { ...state.keyserverStore.keyserverInfos[ authoritativeKeyserverID ], urlPrefix: defaultURLPrefix, }, }, }, } : state, [56]: (state: any) => { const { deviceToken, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [authoritativeKeyserverID]: { ...keyserverStore.keyserverInfos[authoritativeKeyserverID], deviceToken, }, }, }, }; }, [57]: async (state: any) => { const { connection, keyserverStore: { keyserverInfos }, ...rest } = state; const newKeyserverInfos: { [string]: KeyserverInfo } = {}; for (const key in keyserverInfos) { newKeyserverInfos[key] = { ...keyserverInfos[key], connection: { ...defaultConnectionInfo }, }; } return { ...rest, keyserverStore: { ...state.keyserverStore, keyserverInfos: newKeyserverInfos, }, }; }, [58]: async (state: AppState) => { const userStoreOperations: $ReadOnlyArray = [ { type: 'remove_all_users' }, ...convertUserInfosToReplaceUserOps(state.userStore.userInfos), ]; const dbOperations: $ReadOnlyArray = userStoreOpsHandlers.convertOpsToClientDBOps(userStoreOperations); try { await commCoreModule.processDBStoreOperations({ userStoreOperations: dbOperations, }); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return handleReduxMigrationFailure(state); } return state; }, [59]: (state: AppState) => { const clientDBThreadInfos = commCoreModule.getAllThreadsSync(); const rawThreadInfos = clientDBThreadInfos.map( deprecatedConvertClientDBThreadInfoToRawThreadInfo, ); const rawThreadInfosObject = rawThreadInfos.reduce( ( acc: { [string]: LegacyRawThreadInfo }, threadInfo: LegacyRawThreadInfo, ) => { acc[threadInfo.id] = threadInfo; return acc; }, {}, ); const migratedRawThreadInfos = persistMigrationToRemoveSelectRolePermissions(rawThreadInfosObject); const migratedThreadInfosArray = Object.keys(migratedRawThreadInfos).map( id => migratedRawThreadInfos[id], ); const convertedClientDBThreadInfos = migratedThreadInfosArray.map( convertRawThreadInfoToClientDBThreadInfo, ); const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...convertedClientDBThreadInfos.map((thread: ClientDBThreadInfo) => ({ type: 'replace', payload: thread, })), ]; try { commCoreModule.processThreadStoreOperationsSync(operations); } catch (exception) { console.log(exception); return handleReduxMigrationFailure(state); } return state; }, [60]: (state: AppState) => deprecatedUpdateClientDBThreadStoreThreadInfos( state, legacyUpdateRolesAndPermissions, handleReduxMigrationFailure, ), [61]: (state: AppState) => { const minimallyEncodeThreadInfosFunc = ( threadStoreInfos: MixedRawThreadInfos, ): MixedRawThreadInfos => Object.keys(threadStoreInfos).reduce( ( acc: { [string]: LegacyRawThreadInfo | RawThreadInfo, }, key: string, ) => { const threadInfo = threadStoreInfos[key]; acc[threadInfo.id] = threadInfo.minimallyEncoded ? threadInfo - : deprecatedMinimallyEncodeRawThreadInfo(threadInfo); + : minimallyEncodeRawThreadInfoWithMemberPermissions(threadInfo); return acc; }, {}, ); return deprecatedUpdateClientDBThreadStoreThreadInfos( state, minimallyEncodeThreadInfosFunc, handleReduxMigrationFailure, ); }, [62]: async (state: AppState) => { const replaceOps: $ReadOnlyArray = entries( state.keyserverStore.keyserverInfos, ).map(([id, keyserverInfo]) => ({ type: 'replace_keyserver', payload: { id, keyserverInfo, }, })); const dbOperations: $ReadOnlyArray = keyserverStoreOpsHandlers.convertOpsToClientDBOps([ { type: 'remove_all_keyservers' }, ...replaceOps, ]); try { await commCoreModule.processDBStoreOperations({ keyserverStoreOperations: dbOperations, }); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return handleReduxMigrationFailure(state); } return state; }, [63]: async (state: any) => { const { actualizedCalendarQuery, ...rest } = state; const operations: $ReadOnlyArray = entries( state.keyserverStore.keyserverInfos, ).map(([id, keyserverInfo]) => ({ type: 'replace_keyserver', payload: { id, keyserverInfo: { ...keyserverInfo, actualizedCalendarQuery: { ...actualizedCalendarQuery, filters: filterThreadIDsInFilterList( actualizedCalendarQuery.filters, (threadID: string) => extractKeyserverIDFromID(threadID) === id, ), }, }, }, })); const dbOperations: $ReadOnlyArray = keyserverStoreOpsHandlers.convertOpsToClientDBOps(operations); const newState = { ...rest, keyserverStore: keyserverStoreOpsHandlers.processStoreOperations( rest.keyserverStore, operations, ), }; try { await commCoreModule.processDBStoreOperations({ keyserverStoreOperations: dbOperations, }); } catch (exception) { if (isTaskCancelledError(exception)) { return newState; } return handleReduxMigrationFailure(newState); } return newState; }, // Migration 64 is a noop to unblock a `native` release since the previous // contents are not ready to be deployed to prod and we don't want to // decrement migration 65. [64]: (state: AppState) => state, [65]: async (state: AppState) => { const replaceOp: ReplaceIntegrityThreadHashesOperation = { type: 'replace_integrity_thread_hashes', payload: { threadHashes: state.integrityStore.threadHashes, }, }; const dbOperations: $ReadOnlyArray = integrityStoreOpsHandlers.convertOpsToClientDBOps([ { type: 'remove_all_integrity_thread_hashes' }, replaceOp, ]); try { await commCoreModule.processDBStoreOperations({ integrityStoreOperations: dbOperations, }); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return handleReduxMigrationFailure(state); } return state; }, [66]: async (state: AppState) => { const stores = await commCoreModule.getClientDBStore(); const keyserversDBInfo = stores.keyservers; const { translateClientDBData } = keyserverStoreOpsHandlers; const keyservers = translateClientDBData(keyserversDBInfo); // There is no modification of the keyserver data, but the ops handling // should correctly split the data between synced and non-synced tables const replaceOps: $ReadOnlyArray = entries( keyservers, ).map(([id, keyserverInfo]) => ({ type: 'replace_keyserver', payload: { id, keyserverInfo, }, })); const keyserverStoreOperations: $ReadOnlyArray = keyserverStoreOpsHandlers.convertOpsToClientDBOps([ { type: 'remove_all_keyservers' }, ...replaceOps, ]); try { await commCoreModule.processDBStoreOperations({ keyserverStoreOperations, }); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return handleReduxMigrationFailure(state); } return state; }, [67]: (state: any) => { const { nextLocalID, ...rest } = state; return rest; }, [68]: async (state: AppState) => { const { userStore, ...rest } = state; return rest; }, [69]: (state: any) => { const { notifPermissionAlertInfo, ...rest } = state; const newState = { ...rest, alertStore: { alertInfos: defaultAlertInfos, }, }; return newState; }, [70]: (state: any) => { const clientDBMessageInfos = commCoreModule.getAllMessagesSync(); const unsupportedMessageIDsToRemove = clientDBMessageInfos .filter( message => parseInt(message.type) === messageTypes.UNSUPPORTED && parseInt(message.future_type) === messageTypes.UPDATE_RELATIONSHIP, ) .map(message => message.id); const messageStoreOperations: $ReadOnlyArray = [ { type: 'remove', payload: { ids: unsupportedMessageIDsToRemove }, }, ]; try { commCoreModule.processMessageStoreOperationsSync(messageStoreOperations); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [71]: async (state: AppState) => { const replaceOps: $ReadOnlyArray = entries(state.threadActivityStore).map(([threadID, entry]) => ({ type: 'replace_thread_activity_entry', payload: { id: threadID, threadActivityStoreEntry: entry, }, })); const dbOperations: $ReadOnlyArray = threadActivityStoreOpsHandlers.convertOpsToClientDBOps([ { type: 'remove_all_thread_activity_entries' }, ...replaceOps, ]); try { await commCoreModule.processDBStoreOperations({ threadActivityStoreOperations: dbOperations, }); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return handleReduxMigrationFailure(state); } return state; }, [72]: (state: AppState) => updateClientDBThreadStoreThreadInfos( state, patchRawThreadInfosWithSpecialRole, handleReduxMigrationFailure, ), [73]: (state: AppState) => { return { ...state, alertStore: { ...state.alertStore, alertInfos: { ...state.alertStore.alertInfos, [alertTypes.SIWE_BACKUP_MESSAGE]: defaultAlertInfo, }, }, }; }, [74]: (state: AppState) => unshimClientDB( state, [messageTypes.UPDATE_RELATIONSHIP], handleReduxMigrationFailure, ), [75]: async (state: AppState) => { const replaceOps: $ReadOnlyArray = entries( state.entryStore.entryInfos, ).map(([id, entry]) => ({ type: 'replace_entry', payload: { id, entry, }, })); const dbOperations: $ReadOnlyArray = entryStoreOpsHandlers.convertOpsToClientDBOps([ { type: 'remove_all_entries' }, ...replaceOps, ]); try { await commCoreModule.processDBStoreOperations({ entryStoreOperations: dbOperations, }); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return handleReduxMigrationFailure(state); } return state; }, }; type PersistedReportStore = $Diff< ReportStore, { +queuedReports: $ReadOnlyArray }, >; const reportStoreTransform: Transform = createTransform( (state: ReportStore): PersistedReportStore => { return { enabledReports: state.enabledReports }; }, (state: PersistedReportStore): ReportStore => { return { ...state, queuedReports: [] }; }, { whitelist: ['reportStore'] }, ); const migrations = { // This migration doesn't change the store but sets a persisted version // in the DB [75]: (state: AppState) => ({ state, ops: [], }), [76]: (state: AppState) => { const localMessageInfos = state.messageStore.local; const replaceOps: $ReadOnlyArray = entries(localMessageInfos).map(([id, message]) => ({ type: 'replace_local_message_info', payload: { id, localMessageInfo: message, }, })); const operations: $ReadOnlyArray = [ { type: 'remove_all_local_message_infos', }, ...replaceOps, ]; const newMessageStore = messageStoreOpsHandlers.processStoreOperations( state.messageStore, operations, ); const dbOperations: $ReadOnlyArray = messageStoreOpsHandlers.convertOpsToClientDBOps(operations); return { state: { ...state, messageStore: newMessageStore, }, ops: dbOperations, }; }, [77]: (state: AppState) => ({ state, ops: [], }), [78]: (state: AppState) => { const clientDBThreadInfos = commCoreModule.getAllThreadsSync(); const dbOperations = createUpdateDBOpsForThreadStoreThreadInfos( clientDBThreadInfos, deprecatedUpdateRolesAndPermissions, ); return { state, ops: dbOperations, }; }, [79]: (state: AppState) => { return { state: { ...state, tunnelbrokerDeviceToken: { localToken: null, tunnelbrokerToken: null, }, }, ops: [], }; }, }; // NOTE: renaming this object, and especially the `version` property // requires updating `native/native_rust_library/build.rs` to correctly // scrap Redux state version from this file. const persistConfig = { key: 'root', storage: AsyncStorage, blacklist: persistBlacklist, debug: __DEV__, version: 79, transforms: [ messageStoreMessagesBlocklistTransform, reportStoreTransform, keyserverStoreTransform, ], migrate: (createAsyncMigrate( legacyMigrations, { debug: __DEV__ }, migrations, (error: Error, state: AppState) => { if (isTaskCancelledError(error)) { return state; } return handleReduxMigrationFailure(state); }, ): any), timeout: ((__DEV__ ? 0 : 30000): number | void), }; const codeVersion: number = commCoreModule.getCodeVersion(); // This local exists to avoid a circular dependency where redux-setup needs to // import all the navigation and screen stuff, but some of those screens want to // access the persistor to purge its state. let storedPersistor = null; function setPersistor(persistor: *) { storedPersistor = persistor; } function getPersistor(): Persistor { invariant(storedPersistor, 'should be set'); return storedPersistor; } export { persistConfig, codeVersion, setPersistor, getPersistor };