diff --git a/lib/permissions/minimally-encoded-raw-thread-info-validators.js b/lib/permissions/minimally-encoded-raw-thread-info-validators.js index 5fb5d1290..3b70826cc 100644 --- a/lib/permissions/minimally-encoded-raw-thread-info-validators.js +++ b/lib/permissions/minimally-encoded-raw-thread-info-validators.js @@ -1,85 +1,85 @@ // @flow import t, { type TInterface, type TUnion } from 'tcomb'; import { tHexEncodedPermissionsBitmask, tHexEncodedRolePermission, } from './minimally-encoded-thread-permissions.js'; import { specialRoleValidator } from './special-roles.js'; import type { MemberInfo, ThreadCurrentUserInfo, RawThreadInfo, RoleInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { type LegacyRawThreadInfo, legacyMemberInfoValidator, legacyRawThreadInfoValidator, - clientLegacyRoleInfoValidator, legacyThreadCurrentUserInfoValidator, } from '../types/thread-types.js'; import { tBool, tID, tShape } from '../utils/validation-utils.js'; const threadCurrentUserInfoValidator: TInterface = tShape({ ...legacyThreadCurrentUserInfoValidator.meta.props, minimallyEncoded: tBool(true), permissions: tHexEncodedPermissionsBitmask, }); const roleInfoValidator: TInterface = tShape({ - ...clientLegacyRoleInfoValidator.meta.props, + id: tID, + name: t.String, minimallyEncoded: tBool(true), permissions: t.list(tHexEncodedRolePermission), specialRole: t.maybe(specialRoleValidator), }); type RoleInfoPossiblyWithIsDefaultField = $ReadOnly<{ ...RoleInfo, +isDefault?: boolean, }>; // This validator is to be used in `convertClientDBThreadInfoToRawThreadInfo` // which validates the persisted JSON blob BEFORE any migrations are run. // `roleInfoValidator` will fail for persisted `RoleInfo`s that include // the `isDefault` field. Figured it made sense to create a separate validator // instead of adding complexity to `roleInfoValidator` which should maintain // 1:1 correspondance with the `RoleInfo` type. const persistedRoleInfoValidator: TInterface = tShape({ id: tID, name: t.String, minimallyEncoded: tBool(true), permissions: t.list(tHexEncodedRolePermission), specialRole: t.maybe(specialRoleValidator), isDefault: t.maybe(t.Boolean), }); const memberInfoValidator: TInterface = tShape({ ...legacyMemberInfoValidator.meta.props, minimallyEncoded: tBool(true), permissions: tHexEncodedPermissionsBitmask, }); const rawThreadInfoValidator: TInterface = tShape( { ...legacyRawThreadInfoValidator.meta.props, minimallyEncoded: tBool(true), members: t.list(memberInfoValidator), roles: t.dict(tID, roleInfoValidator), currentUser: threadCurrentUserInfoValidator, }, ); const mixedRawThreadInfoValidator: TUnion = t.union([legacyRawThreadInfoValidator, rawThreadInfoValidator]); export { memberInfoValidator, roleInfoValidator, persistedRoleInfoValidator, threadCurrentUserInfoValidator, rawThreadInfoValidator, mixedRawThreadInfoValidator, }; diff --git a/lib/permissions/minimally-encoded-thread-permissions-test-data.js b/lib/permissions/minimally-encoded-thread-permissions-test-data.js index efc7a494b..317f35191 100644 --- a/lib/permissions/minimally-encoded-thread-permissions-test-data.js +++ b/lib/permissions/minimally-encoded-thread-permissions-test-data.js @@ -1,703 +1,702 @@ // @flow import type { RawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { LegacyRawThreadInfo } from '../types/thread-types.js'; const exampleRawThreadInfoA: LegacyRawThreadInfo = { id: '85171', type: threadTypes.PERSONAL, name: '', description: '', color: '6d49ab', creationTime: 1675887298557, parentThreadID: '1', members: [ { id: '256', role: null, permissions: { know_of: { value: true, source: '1', }, visible: { value: true, source: '1', }, voiced: { value: true, source: '1', }, edit_entries: { value: true, source: '1', }, edit_thread: { value: true, source: '1', }, edit_thread_description: { value: true, source: '1', }, edit_thread_color: { value: true, source: '1', }, delete_thread: { value: true, source: '1', }, create_subthreads: { value: true, source: '1', }, create_sidebars: { value: true, source: '1', }, join_thread: { value: true, source: '1', }, edit_permissions: { value: true, source: '1', }, add_members: { value: true, source: '1', }, remove_members: { value: true, source: '1', }, change_role: { value: true, source: '1', }, leave_thread: { value: false, source: null, }, react_to_message: { value: false, source: null, }, edit_message: { value: false, source: null, }, manage_pins: { value: true, source: '1', }, voiced_in_announcement_channels: { value: false, source: null, }, }, isSender: false, }, { id: '83853', role: '85172', permissions: { know_of: { value: true, source: '85171', }, visible: { value: true, source: '85171', }, voiced: { value: true, source: '85171', }, edit_entries: { value: true, source: '85171', }, edit_thread: { value: true, source: '85171', }, edit_thread_description: { value: true, source: '85171', }, edit_thread_color: { value: true, source: '85171', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: '85171', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: '85171', }, edit_message: { value: true, source: '85171', }, manage_pins: { value: false, source: null, }, voiced_in_announcement_channels: { value: false, source: null, }, }, isSender: true, }, ], roles: { '85172': { id: '85172', name: 'Members', permissions: { know_of: true, visible: true, voiced: true, react_to_message: true, edit_message: true, edit_entries: true, edit_thread: true, edit_thread_color: true, edit_thread_description: true, create_sidebars: true, descendant_open_know_of: true, descendant_open_visible: true, child_open_join_thread: true, }, isDefault: true, }, }, currentUser: { role: '85172', permissions: { know_of: { value: true, source: '85171', }, visible: { value: true, source: '85171', }, voiced: { value: true, source: '85171', }, edit_entries: { value: true, source: '85171', }, edit_thread: { value: true, source: '85171', }, edit_thread_description: { value: true, source: '85171', }, edit_thread_color: { value: true, source: '85171', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: '85171', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: '85171', }, edit_message: { value: true, source: '85171', }, manage_pins: { value: false, source: null, }, voiced_in_announcement_channels: { value: false, source: null, }, }, subscription: { home: true, pushNotifs: true, }, unread: false, }, repliesCount: 0, containingThreadID: '1', community: '1', pinnedCount: 0, }; const exampleMinimallyEncodedRawThreadInfoA: RawThreadInfo = { minimallyEncoded: true, id: '85171', type: threadTypes.PERSONAL, name: '', description: '', color: '6d49ab', creationTime: 1675887298557, parentThreadID: '1', members: [ { minimallyEncoded: true, id: '256', role: null, permissions: '87fff', isSender: false, }, { minimallyEncoded: true, id: '83853', role: '85172', permissions: '3027f', isSender: true, }, ], roles: { '85172': { minimallyEncoded: true, id: '85172', name: 'Members', permissions: [ '000', '010', '020', '100', '110', '030', '040', '060', '050', '090', '005', '015', '0a9', ], - isDefault: true, }, }, currentUser: { minimallyEncoded: true, role: '85172', permissions: '3027f', subscription: { home: true, pushNotifs: true, }, unread: false, }, repliesCount: 0, containingThreadID: '1', community: '1', pinnedCount: 0, }; const expectedDecodedExampleRawThreadInfoA: LegacyRawThreadInfo = { id: '85171', type: threadTypes.PERSONAL, name: '', description: '', color: '6d49ab', creationTime: 1675887298557, parentThreadID: '1', members: [ { id: '256', role: null, permissions: { know_of: { value: true, source: 'null', }, visible: { value: true, source: 'null', }, voiced: { value: true, source: 'null', }, edit_entries: { value: true, source: 'null', }, edit_thread: { value: true, source: 'null', }, edit_thread_description: { value: true, source: 'null', }, edit_thread_color: { value: true, source: 'null', }, delete_thread: { value: true, source: 'null', }, create_subthreads: { value: true, source: 'null', }, create_sidebars: { value: true, source: 'null', }, join_thread: { value: true, source: 'null', }, edit_permissions: { value: true, source: 'null', }, add_members: { value: true, source: 'null', }, remove_members: { value: true, source: 'null', }, change_role: { value: true, source: 'null', }, leave_thread: { value: false, source: null, }, react_to_message: { value: false, source: null, }, edit_message: { value: false, source: null, }, manage_pins: { value: true, source: 'null', }, manage_invite_links: { source: null, value: false, }, edit_thread_avatar: { source: null, value: false, }, voiced_in_announcement_channels: { value: false, source: null, }, }, isSender: false, }, { id: '83853', role: '85172', permissions: { know_of: { value: true, source: 'null', }, visible: { value: true, source: 'null', }, voiced: { value: true, source: 'null', }, edit_entries: { value: true, source: 'null', }, edit_thread: { value: true, source: 'null', }, edit_thread_description: { value: true, source: 'null', }, edit_thread_color: { value: true, source: 'null', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: 'null', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: 'null', }, edit_message: { value: true, source: 'null', }, manage_pins: { value: false, source: null, }, manage_invite_links: { source: null, value: false, }, edit_thread_avatar: { source: null, value: false, }, voiced_in_announcement_channels: { value: false, source: null, }, }, isSender: true, }, ], roles: { '85172': { id: '85172', name: 'Members', permissions: { know_of: true, visible: true, voiced: true, react_to_message: true, edit_message: true, edit_entries: true, edit_thread: true, edit_thread_color: true, edit_thread_description: true, create_sidebars: true, descendant_open_know_of: true, descendant_open_visible: true, child_open_join_thread: true, }, isDefault: true, }, }, currentUser: { role: '85172', permissions: { know_of: { value: true, source: 'null', }, visible: { value: true, source: 'null', }, voiced: { value: true, source: 'null', }, edit_entries: { value: true, source: 'null', }, edit_thread: { value: true, source: 'null', }, edit_thread_description: { value: true, source: 'null', }, edit_thread_color: { value: true, source: 'null', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: 'null', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: 'null', }, edit_message: { value: true, source: 'null', }, manage_pins: { value: false, source: null, }, manage_invite_links: { source: null, value: false, }, edit_thread_avatar: { source: null, value: false, }, voiced_in_announcement_channels: { value: false, source: null, }, }, subscription: { home: true, pushNotifs: true, }, unread: false, }, repliesCount: 0, containingThreadID: '1', community: '1', pinnedCount: 0, }; export { exampleRawThreadInfoA, exampleMinimallyEncodedRawThreadInfoA, expectedDecodedExampleRawThreadInfoA, }; diff --git a/lib/permissions/minimally-encoded-thread-permissions.test.js b/lib/permissions/minimally-encoded-thread-permissions.test.js index cf683108f..86c897d94 100644 --- a/lib/permissions/minimally-encoded-thread-permissions.test.js +++ b/lib/permissions/minimally-encoded-thread-permissions.test.js @@ -1,661 +1,660 @@ // @flow import { memberInfoValidator, 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 { minimallyEncodeRawThreadInfo, decodeMinimallyEncodedRawThreadInfo, 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 }, }; 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 }, }; 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 }, }); }); }); 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'], - isDefault: true, 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( memberInfoValidator.is({ minimallyEncoded: true, id: 'memberID', permissions: 'ABCDEF', isSender: true, }), ).toBe(true); expect( memberInfoValidator.is({ minimallyEncoded: true, id: 'memberID', permissions: '01b', isSender: false, }), ).toBe(true); }); it('should NOT validate malformed MinimallyEncodedMemberInfo', () => { expect( memberInfoValidator.is({ minimallyEncoded: true, id: 'memberID', permissions: 'INVALID', isSender: false, }), ).toBe(false); expect( memberInfoValidator.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( minimallyEncodeRawThreadInfo(exampleRawThreadInfoA), ), ).toBe(true); }); }); describe('decodeMinimallyEncodedRawThreadInfo', () => { it('should correctly decode minimallyEncodedRawThreadInfo', () => { expect( decodeMinimallyEncodedRawThreadInfo( minimallyEncodeRawThreadInfo(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, }, }, 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/permissions/special-roles.test.js b/lib/permissions/special-roles.test.js index ae71be688..7942953f2 100644 --- a/lib/permissions/special-roles.test.js +++ b/lib/permissions/special-roles.test.js @@ -1,227 +1,223 @@ // @flow import { patchRawThreadInfosWithSpecialRole, patchRoleInfoWithSpecialRole, specialRoles, } from './special-roles.js'; import type { RoleInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { RawThreadInfos } from '../types/thread-types.js'; describe('patchRoleInfoWithSpecialRole', () => { it('should correctly set DEFAULT_ROLE', () => { const role: RoleInfo = { minimallyEncoded: true, id: 'roleID', name: 'roleName', permissions: ['abc', 'def'], - isDefault: true, + specialRole: specialRoles.DEFAULT_ROLE, }; const patchedRole = patchRoleInfoWithSpecialRole(role); expect(patchedRole.specialRole).toBe(specialRoles.DEFAULT_ROLE); }); it('should correctly set ADMIN_ROLE', () => { const role: RoleInfo = { minimallyEncoded: true, id: 'roleID', name: 'Admins', permissions: ['abc', 'def'], - isDefault: false, }; const patchedRole = patchRoleInfoWithSpecialRole(role); expect(patchedRole.specialRole).toBe(specialRoles.ADMIN_ROLE); }); it('should correctly set undefined', () => { const role: RoleInfo = { minimallyEncoded: true, id: 'roleID', name: 'BLAH', permissions: ['abc', 'def'], - isDefault: false, }; const patchedRole = patchRoleInfoWithSpecialRole(role); expect(patchedRole.specialRole).toBe(undefined); }); }); const rawThreadInfos: RawThreadInfos = { '256|1': { minimallyEncoded: true, id: '256|1', type: threadTypes.GENESIS, name: 'GENESIS', description: 'This is the first community on Comm. In the future it will be possible to create chats outside of a community, but for now all of these chats get set with GENESIS as their parent. GENESIS is hosted on Ashoat’s keyserver.', color: 'c85000', creationTime: 1702415956354, parentThreadID: null, containingThreadID: null, community: null, members: [ { id: '256', role: '256|83796', permissions: '3f73ff', isSender: false, minimallyEncoded: true, }, { id: '83809', role: '256|83795', permissions: '3', isSender: false, minimallyEncoded: true, }, ], roles: { '256|83795': { id: '256|83795', name: 'Members', permissions: ['000', '010', '005', '015', '0a7'], - isDefault: true, minimallyEncoded: true, + specialRole: specialRoles.DEFAULT_ROLE, }, '256|83796': { id: '256|83796', name: 'Admins', permissions: [ '000', '010', '020', '100', '110', '030', '040', '060', '050', '120', '080', '090', '0c0', '070', '0d0', '0e0', '130', '140', '150', '004', '014', '0a6', '0a8', '024', '034', '044', '064', '054', '124', '086', '096', '0c4', '074', '0b4', '0d4', '0e4', '134', '156', ], - isDefault: false, minimallyEncoded: true, }, }, currentUser: { role: '256|83795', permissions: '3', subscription: { home: true, pushNotifs: true, }, unread: false, minimallyEncoded: true, }, repliesCount: 0, pinnedCount: 0, }, '256|83814': { id: '256|83814', type: threadTypes.PRIVATE, name: '', description: 'This is your private chat, where you can set reminders and jot notes in private!', color: 'aa4b4b', creationTime: 1702415964471, parentThreadID: '256|1', repliesCount: 0, containingThreadID: '256|1', community: '256|1', pinnedCount: 0, minimallyEncoded: true, members: [ { id: '256', role: null, permissions: '2c7fff', isSender: false, minimallyEncoded: true, }, { id: '83809', role: '256|83815', permissions: '3026f', isSender: true, minimallyEncoded: true, }, ], roles: { '256|83815': { id: '256|83815', name: 'NotMembers', permissions: [ '000', '010', '020', '100', '110', '060', '050', '090', '030', '005', '015', '0a9', ], - isDefault: false, minimallyEncoded: true, }, }, currentUser: { role: '256|83815', permissions: '3026f', subscription: { home: true, pushNotifs: true, }, unread: false, minimallyEncoded: true, }, }, }; describe('patchRawThreadInfosWithSpecialRole', () => { it('should correctly set special roles', () => { const patchedRawThreadInfos = patchRawThreadInfosWithSpecialRole(rawThreadInfos); expect(patchedRawThreadInfos['256|1'].roles['256|83795'].specialRole).toBe( specialRoles.DEFAULT_ROLE, ); expect(patchedRawThreadInfos['256|1'].roles['256|83796'].specialRole).toBe( specialRoles.ADMIN_ROLE, ); expect( patchedRawThreadInfos['256|83814'].roles['256|83815'].specialRole, ).toBe(undefined); }); }); diff --git a/lib/reducers/calendar-filters-reducer.test.js b/lib/reducers/calendar-filters-reducer.test.js index 5cb344dcd..ade99a219 100644 --- a/lib/reducers/calendar-filters-reducer.test.js +++ b/lib/reducers/calendar-filters-reducer.test.js @@ -1,222 +1,219 @@ // @flow import reduceCalendarFilters, { removeDeletedThreadIDsFromFilterList, removeKeyserverThreadIDsFromFilterList, } from './calendar-filters-reducer.js'; import { keyserverAuthActionTypes } from '../actions/user-actions.js'; import type { RawMessageInfo } from '../types/message-types.js'; import type { ThreadStore } from '../types/thread-types'; const calendarFilters = [ { type: 'threads', threadIDs: ['256|1', '256|83815'] }, ]; const threadStore: ThreadStore = { threadInfos: { '256|1': { id: '256|1', type: 12, name: 'GENESIS', description: '', color: '648caa', creationTime: 1689091732528, parentThreadID: null, repliesCount: 0, containingThreadID: null, community: null, pinnedCount: 0, minimallyEncoded: true, members: [ { id: '256', role: '256|83796', permissions: '3f73ff', isSender: true, minimallyEncoded: true, }, { id: '83810', role: '256|83795', permissions: '3', isSender: false, minimallyEncoded: true, }, ], roles: { '256|83795': { id: '256|83795', name: 'Members', permissions: ['000', '010', '005', '015', '0a7'], - isDefault: true, minimallyEncoded: true, }, '256|83796': { id: '256|83796', name: 'Admins', permissions: ['000', '010', '005', '015', '0a7'], - isDefault: false, minimallyEncoded: true, }, }, currentUser: { role: '256|83795', permissions: '3', subscription: { home: true, pushNotifs: true, }, unread: false, minimallyEncoded: true, }, }, '256|83815': { id: '256|83815', type: 7, name: '', description: 'This is your private chat, where you can set reminders and jot notes in private!', color: '57697f', creationTime: 1689248242797, parentThreadID: '256|1', repliesCount: 0, containingThreadID: '256|1', community: '256|1', pinnedCount: 0, minimallyEncoded: true, members: [ { id: '256', role: null, permissions: '2c7fff', isSender: false, minimallyEncoded: true, }, { id: '83810', role: '256|83816', permissions: '3026f', isSender: true, minimallyEncoded: true, }, ], roles: { '256|83816': { id: '256|83816', name: 'Members', permissions: ['000', '010', '005', '015', '0a7'], - isDefault: true, minimallyEncoded: true, }, }, currentUser: { role: null, permissions: '3026f', subscription: { home: true, pushNotifs: true, }, unread: false, minimallyEncoded: true, }, }, }, }; describe('removeDeletedThreadIDsFromFilterList', () => { it('Removes threads the user is not a member of anymore', () => { expect( removeDeletedThreadIDsFromFilterList( calendarFilters, threadStore.threadInfos, ), ).toEqual([{ type: 'threads', threadIDs: ['256|1'] }]); }); }); const threadIDsToStay = [ '256|1', '256|2', '200|4', '300|5', '300|6', '256|100', ]; const keyserverToRemove1 = '100'; const keyserverToRemove2 = '400'; const threadIDsToRemove = [ keyserverToRemove1 + '|3', keyserverToRemove1 + '|7', keyserverToRemove2 + '|8', ]; const calendarFiltersState = [ { type: 'not_deleted' }, { type: 'threads', threadIDs: [...threadIDsToStay, ...threadIDsToRemove], }, ]; describe('removeKeyserverThreadIDsFromFilterList', () => { it('Removes threads from the given keyserver', () => { expect( removeKeyserverThreadIDsFromFilterList(calendarFiltersState, [ keyserverToRemove1, keyserverToRemove2, ]), ).toEqual([ { type: 'not_deleted' }, { type: 'threads', threadIDs: threadIDsToStay, }, ]); }); }); describe('reduceCalendarFilters', () => { it('Removes from filters thread ids of the keyservers that were logged into', () => { const messageInfos: RawMessageInfo[] = []; const payload = { currentUserInfo: { id: '5', username: 'me' }, preRequestUserInfo: { id: '5', username: 'me' }, threadInfos: {}, messagesResult: { currentAsOf: {}, messageInfos, truncationStatus: {}, watchedIDsAtRequestTime: [], }, userInfos: [], calendarResult: { rawEntryInfos: [], calendarQuery: { startDate: '0', endDate: '0', filters: [] }, }, // only updatesCurrentAsOf is relevant to this test updatesCurrentAsOf: { [keyserverToRemove1]: 5, [keyserverToRemove2]: 5 }, authActionSource: 'SOCKET_AUTH_ERROR_RESOLUTION_ATTEMPT', }; expect( reduceCalendarFilters( calendarFiltersState, { type: keyserverAuthActionTypes.success, payload, loadingInfo: { customKeyName: null, trackMultipleRequests: false, fetchIndex: 1, }, }, { threadInfos: {} }, ), ).toEqual([ { type: 'not_deleted' }, { type: 'threads', threadIDs: threadIDsToStay, }, ]); }); }); diff --git a/lib/shared/thread-utils.test.js b/lib/shared/thread-utils.test.js index 8a62e5f6a..db46df561 100644 --- a/lib/shared/thread-utils.test.js +++ b/lib/shared/thread-utils.test.js @@ -1,201 +1,199 @@ // @flow import { parsePendingThreadID, threadInfoFromRawThreadInfo, } from './thread-utils.js'; import { threadInfoValidator } from '../permissions/minimally-encoded-thread-permissions-validators.js'; import type { RawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { UserInfos } from '../types/user-types.js'; describe('parsePendingThreadID(pendingThreadID: string)', () => { it('should return correct data for real pending sidebar ID', () => { const sidebarResult = { threadType: threadTypes.SIDEBAR, memberIDs: [], sourceMessageID: '12345', }; expect(parsePendingThreadID('pending/sidebar/12345')).toStrictEqual( sidebarResult, ); const sidebarResultWithNewSchema = { threadType: threadTypes.SIDEBAR, memberIDs: [], sourceMessageID: '789|12345', }; expect(parsePendingThreadID('pending/sidebar/789|12345')).toStrictEqual( sidebarResultWithNewSchema, ); }); it('should return correct data for real pending sidebar ID', () => { const pendingPersonalResult = { threadType: threadTypes.PERSONAL, memberIDs: ['83810', '86622'], sourceMessageID: null, }; expect(parsePendingThreadID('pending/type6/83810+86622')).toStrictEqual( pendingPersonalResult, ); const pendingCommunityOpenResult = { threadType: threadTypes.COMMUNITY_OPEN_SUBTHREAD, memberIDs: ['83810', '86622', '83889'], sourceMessageID: null, }; expect( parsePendingThreadID('pending/type3/83810+86622+83889'), ).toStrictEqual(pendingCommunityOpenResult); }); it('should return null when there are missing information in ID', () => { expect(parsePendingThreadID('pending/type4/')).toBeNull(); expect(parsePendingThreadID('type12/83810+86622')).toBeNull(); expect(parsePendingThreadID('pending/83810')).toBeNull(); expect(parsePendingThreadID('pending')).toBeNull(); expect(parsePendingThreadID('')).toBeNull(); expect(parsePendingThreadID('pending/something/12345')).toBeNull(); }); it('should return null when the format is invalid', () => { expect(parsePendingThreadID('someothertext/type1/12345')).toBeNull(); expect(parsePendingThreadID('pending/type6/12312+++11+12')).toBeNull(); expect(parsePendingThreadID('pending/type3/83810+')).toBeNull(); }); it('should throw invariant violation when thread type is invalid ', () => { expect(() => parsePendingThreadID('pending/type123/12345')).toThrowError( 'number is not ThreadType enum', ); }); }); const rawThreadInfo: RawThreadInfo = { id: '1', type: threadTypes.GENESIS, name: 'GENESIS', description: 'This is the first community on Comm. In the future it will be possible to create chats outside of a community, but for now all of these chats get set with GENESIS as their parent. GENESIS is hosted on Ashoat’s keyserver.', color: 'c85000', creationTime: 1702415956354, parentThreadID: null, repliesCount: 0, containingThreadID: null, community: null, pinnedCount: 0, minimallyEncoded: true, members: [ { id: '256', role: '83796', permissions: '3f73ff', isSender: false, minimallyEncoded: true, }, { id: '83809', role: '83795', permissions: '3', isSender: false, minimallyEncoded: true, }, ], roles: { '83795': { id: '83795', name: 'Members', permissions: ['000', '010', '005', '015', '0a7'], - isDefault: true, minimallyEncoded: true, }, '83796': { id: '83796', name: 'Admins', permissions: [ '000', '010', '020', '100', '110', '030', '040', '060', '050', '120', '080', '090', '0c0', '070', '0d0', '0e0', '130', '140', '150', '004', '014', '0a6', '0a8', '024', '034', '044', '064', '054', '124', '086', '096', '0c4', '074', '0b4', '0d4', '0e4', '134', '156', ], - isDefault: false, minimallyEncoded: true, }, }, currentUser: { role: '83795', permissions: '3', subscription: { home: true, pushNotifs: true, }, unread: false, minimallyEncoded: true, }, }; const userInfos: UserInfos = { '5': { id: '5', username: 'commbot', }, '256': { id: '256', username: 'ashoat', }, '83809': { id: '83809', username: 'atul', avatar: { type: 'emoji', emoji: '😲', color: '4b87aa', }, }, }; describe('threadInfoFromRawThreadInfo', () => { it('should return correctly formed ThreadInfo from RawThreadInfo', () => { const threadInfo = threadInfoFromRawThreadInfo( rawThreadInfo, null, userInfos, ); expect(threadInfoValidator.is(threadInfo)).toBe(true); }); }); diff --git a/lib/types/minimally-encoded-thread-permissions-types.js b/lib/types/minimally-encoded-thread-permissions-types.js index a69365bfd..1f22cea28 100644 --- a/lib/types/minimally-encoded-thread-permissions-types.js +++ b/lib/types/minimally-encoded-thread-permissions-types.js @@ -1,197 +1,209 @@ // @flow import invariant from 'invariant'; import _mapValues from 'lodash/fp/mapValues.js'; import type { ClientAvatar } from './avatar-types.js'; import type { ThreadType } from './thread-types-enum.js'; import type { LegacyMemberInfo, LegacyRawThreadInfo, ClientLegacyRoleInfo, LegacyThreadCurrentUserInfo, } 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'; export type RoleInfo = $ReadOnly<{ - ...ClientLegacyRoleInfo, + +id: string, + +name: string, +minimallyEncoded: true, +permissions: $ReadOnlyArray, +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 { - ...roleInfo, + ...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 MemberInfo = $ReadOnly<{ ...LegacyMemberInfo, +minimallyEncoded: true, +permissions: string, }>; const minimallyEncodeMemberInfo = ( memberInfo: LegacyMemberInfo, ): MemberInfo => { invariant( !('minimallyEncoded' in memberInfo), 'memberInfo is already minimally encoded.', ); return { ...memberInfo, minimallyEncoded: true, permissions: permissionsToBitmaskHex(memberInfo.permissions), }; }; const decodeMinimallyEncodedMemberInfo = ( minimallyEncodedMemberInfo: MemberInfo, ): LegacyMemberInfo => { const { minimallyEncoded, ...rest } = minimallyEncodedMemberInfo; return { ...rest, permissions: threadPermissionsFromBitmaskHex( minimallyEncodedMemberInfo.permissions, ), }; }; export type RelativeMemberInfo = $ReadOnly<{ ...MemberInfo, +username: ?string, +isViewer: boolean, }>; export type RawThreadInfo = $ReadOnly<{ ...LegacyRawThreadInfo, +minimallyEncoded: true, +members: $ReadOnlyArray, +roles: { +[id: string]: RoleInfo }, +currentUser: ThreadCurrentUserInfo, }>; const minimallyEncodeRawThreadInfo = ( rawThreadInfo: LegacyRawThreadInfo, ): RawThreadInfo => { invariant( !('minimallyEncoded' in rawThreadInfo), 'rawThreadInfo is already minimally encoded.', ); const { members, roles, currentUser, ...rest } = rawThreadInfo; return { ...rest, minimallyEncoded: true, members: members.map(minimallyEncodeMemberInfo), roles: _mapValues(minimallyEncodeRoleInfo)(roles), currentUser: minimallyEncodeThreadCurrentUserInfo(currentUser), }; }; const decodeMinimallyEncodedRawThreadInfo = ( minimallyEncodedRawThreadInfo: RawThreadInfo, ): LegacyRawThreadInfo => { const { minimallyEncoded, members, roles, currentUser, ...rest } = minimallyEncodedRawThreadInfo; return { ...rest, members: members.map(decodeMinimallyEncodedMemberInfo), roles: _mapValues(decodeMinimallyEncodedRoleInfo)(roles), currentUser: decodeMinimallyEncodedThreadCurrentUserInfo(currentUser), }; }; 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, minimallyEncodeRawThreadInfo, decodeMinimallyEncodedRawThreadInfo, };