diff --git a/lib/selectors/calendar-selectors.js b/lib/selectors/calendar-selectors.js index 38e37a92d..8f11ec21c 100644 --- a/lib/selectors/calendar-selectors.js +++ b/lib/selectors/calendar-selectors.js @@ -1,75 +1,77 @@ // @flow import * as React from 'react'; import { rawEntryInfoWithinActiveRange } from '../shared/entry-utils'; import SearchIndex from '../shared/search-index'; import { threadInFilterList } from '../shared/thread-utils'; import type { FilterThreadInfo } from '../types/filter-types'; +import { useResolvedThreadInfosObj } from '../utils/entity-helpers'; import { values } from '../utils/objects'; import { useSelector } from '../utils/redux-utils'; import { currentCalendarQuery } from './nav-selectors'; import { threadInfoSelector } from './thread-selectors'; function useFilterThreadInfos( calendarActive: boolean, ): $ReadOnlyArray { - const threadInfos = useSelector(threadInfoSelector); + const unresolvedThreadInfos = useSelector(threadInfoSelector); + const threadInfos = useResolvedThreadInfosObj(unresolvedThreadInfos); const rawEntryInfos = useSelector(state => state.entryStore.entryInfos); const calendarQueryFunc = useSelector(currentCalendarQuery); const calendarQuery = calendarQueryFunc(calendarActive); return React.useMemo(() => { const result: { [threadID: string]: FilterThreadInfo } = {}; for (const entryID in rawEntryInfos) { const rawEntryInfo = rawEntryInfos[entryID]; if (!rawEntryInfoWithinActiveRange(rawEntryInfo, calendarQuery)) { continue; } const threadID = rawEntryInfo.threadID; const threadInfo = threadInfos[rawEntryInfo.threadID]; if (!threadInFilterList(threadInfo)) { continue; } if (result[threadID]) { result[threadID] = { ...result[threadID], numVisibleEntries: result[threadID].numVisibleEntries + 1, }; } else { result[threadID] = { threadInfo, numVisibleEntries: 1, }; } } for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!result[threadID] && threadInFilterList(threadInfo)) { result[threadID] = { threadInfo, numVisibleEntries: 0, }; } } return values(result).sort( (first: FilterThreadInfo, second: FilterThreadInfo) => second.numVisibleEntries - first.numVisibleEntries, ); }, [threadInfos, rawEntryInfos, calendarQuery]); } function useFilterThreadSearchIndex(calendarActive: boolean): SearchIndex { const threadInfos = useFilterThreadInfos(calendarActive); return React.useMemo(() => { const searchIndex = new SearchIndex(); for (const filterThreadInfo of threadInfos) { const { threadInfo } = filterThreadInfo; searchIndex.addEntry(threadInfo.id, threadInfo.uiName); } return searchIndex; }, [threadInfos]); } export { useFilterThreadInfos, useFilterThreadSearchIndex }; diff --git a/lib/types/filter-types.js b/lib/types/filter-types.js index c9b5306fa..db99664c6 100644 --- a/lib/types/filter-types.js +++ b/lib/types/filter-types.js @@ -1,34 +1,34 @@ // @flow -import { type ThreadInfo } from './thread-types'; +import type { ResolvedThreadInfo } from './thread-types'; export const calendarThreadFilterTypes = Object.freeze({ THREAD_LIST: 'threads', NOT_DELETED: 'not_deleted', }); export type CalendarThreadFilterType = $Values< typeof calendarThreadFilterTypes, >; export type CalendarThreadFilter = { +type: 'threads', +threadIDs: $ReadOnlyArray, }; export type CalendarFilter = { +type: 'not_deleted' } | CalendarThreadFilter; export const defaultCalendarFilters: $ReadOnlyArray = [ { type: calendarThreadFilterTypes.NOT_DELETED }, ]; export const updateCalendarThreadFilter = 'UPDATE_CALENDAR_THREAD_FILTER'; export const clearCalendarThreadFilter = 'CLEAR_CALENDAR_THREAD_FILTER'; export const setCalendarDeletedFilter = 'SET_CALENDAR_DELETED_FILTER'; export type SetCalendarDeletedFilterPayload = { +includeDeleted: boolean, }; export type FilterThreadInfo = { - +threadInfo: ThreadInfo, + +threadInfo: ResolvedThreadInfo, +numVisibleEntries: number, }; diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js index b079c7831..0a64df3e7 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,449 +1,467 @@ // @flow import invariant from 'invariant'; import type { Shape } from './core'; import type { CalendarQuery, RawEntryInfo } from './entry-types'; import type { RawMessageInfo, MessageTruncationStatuses, } from './message-types'; import type { ThreadSubscription } from './subscription-types'; import type { ServerUpdateInfo, ClientUpdateInfo } from './update-types'; import type { UserInfo, UserInfos } from './user-types'; export const threadTypes = Object.freeze({ //OPEN: 0, (DEPRECATED) //CLOSED: 1, (DEPRECATED) //SECRET: 2, (DEPRECATED) // has parent, not top-level (appears under parent in inbox), and visible to // all members of parent SIDEBAR: 5, // canonical thread for each pair of users. represents the friendship PERSONAL: 6, // canonical thread for each single user PRIVATE: 7, // local "thick" thread (outside of community). no parent, can only have // sidebar children. currently a proxy for COMMUNITY_SECRET_SUBTHREAD until we // launch actual E2E LOCAL: 4, // aka "org". no parent, top-level, has admin COMMUNITY_ROOT: 8, // like COMMUNITY_ROOT, but members aren't voiced COMMUNITY_ANNOUNCEMENT_ROOT: 9, // an open subthread. has parent, top-level (not sidebar), and visible to all // members of parent. root ancestor is a COMMUNITY_ROOT COMMUNITY_OPEN_SUBTHREAD: 3, // like COMMUNITY_SECRET_SUBTHREAD, but members aren't voiced COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD: 10, // a secret subthread. optional parent, top-level (not sidebar), visible only // to its members. root ancestor is a COMMUNITY_ROOT COMMUNITY_SECRET_SUBTHREAD: 4, // like COMMUNITY_SECRET_SUBTHREAD, but members aren't voiced COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD: 11, // like COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD, but you can't leave GENESIS: 12, }); export type ThreadType = $Values; export function assertThreadType(threadType: number): ThreadType { invariant( threadType === 3 || threadType === 4 || threadType === 5 || threadType === 6 || threadType === 7 || threadType === 8 || threadType === 9 || threadType === 10 || threadType === 11 || threadType === 12, 'number is not ThreadType enum', ); return threadType; } export const communityThreadTypes: $ReadOnlyArray = Object.freeze([ threadTypes.COMMUNITY_ROOT, threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT, threadTypes.GENESIS, ]); export const communitySubthreads: $ReadOnlyArray = Object.freeze([ threadTypes.COMMUNITY_OPEN_SUBTHREAD, threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD, threadTypes.COMMUNITY_SECRET_SUBTHREAD, threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD, ]); export function threadTypeIsCommunityRoot(threadType: ThreadType): boolean { return communityThreadTypes.includes(threadType); } export const threadPermissions = Object.freeze({ KNOW_OF: 'know_of', MEMBERSHIP_DEPRECATED: 'membership', VISIBLE: 'visible', VOICED: 'voiced', EDIT_ENTRIES: 'edit_entries', EDIT_THREAD_NAME: 'edit_thread', EDIT_THREAD_DESCRIPTION: 'edit_thread_description', EDIT_THREAD_COLOR: 'edit_thread_color', DELETE_THREAD: 'delete_thread', CREATE_SUBCHANNELS: 'create_subthreads', CREATE_SIDEBARS: 'create_sidebars', JOIN_THREAD: 'join_thread', EDIT_PERMISSIONS: 'edit_permissions', ADD_MEMBERS: 'add_members', REMOVE_MEMBERS: 'remove_members', CHANGE_ROLE: 'change_role', LEAVE_THREAD: 'leave_thread', }); export type ThreadPermission = $Values; export function assertThreadPermissions( ourThreadPermissions: string, ): ThreadPermission { invariant( ourThreadPermissions === 'know_of' || ourThreadPermissions === 'membership' || ourThreadPermissions === 'visible' || ourThreadPermissions === 'voiced' || ourThreadPermissions === 'edit_entries' || ourThreadPermissions === 'edit_thread' || ourThreadPermissions === 'edit_thread_description' || ourThreadPermissions === 'edit_thread_color' || ourThreadPermissions === 'delete_thread' || ourThreadPermissions === 'create_subthreads' || ourThreadPermissions === 'create_sidebars' || ourThreadPermissions === 'join_thread' || ourThreadPermissions === 'edit_permissions' || ourThreadPermissions === 'add_members' || ourThreadPermissions === 'remove_members' || ourThreadPermissions === 'change_role' || ourThreadPermissions === 'leave_thread', 'string is not threadPermissions enum', ); return ourThreadPermissions; } export const threadPermissionPropagationPrefixes = Object.freeze({ DESCENDANT: 'descendant_', CHILD: 'child_', }); export type ThreadPermissionPropagationPrefix = $Values< typeof threadPermissionPropagationPrefixes, >; export const threadPermissionFilterPrefixes = Object.freeze({ // includes only SIDEBAR, COMMUNITY_OPEN_SUBTHREAD, // COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD OPEN: 'open_', // excludes only SIDEBAR TOP_LEVEL: 'toplevel_', // includes only COMMUNITY_OPEN_SUBTHREAD, // COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD OPEN_TOP_LEVEL: 'opentoplevel_', }); export type ThreadPermissionFilterPrefix = $Values< typeof threadPermissionFilterPrefixes, >; export type ThreadPermissionInfo = | { +value: true, +source: string } | { +value: false, +source: null }; export type ThreadPermissionsBlob = { +[permission: string]: ThreadPermissionInfo, }; export type ThreadRolePermissionsBlob = { +[permission: string]: boolean }; export type ThreadPermissionsInfo = { +[permission: ThreadPermission]: ThreadPermissionInfo, }; export type MemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +isSender: boolean, }; export type RelativeMemberInfo = { ...MemberInfo, +username: ?string, +isViewer: boolean, }; export type RoleInfo = { +id: string, +name: string, +permissions: ThreadRolePermissionsBlob, +isDefault: boolean, }; export type ThreadCurrentUserInfo = { +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, }; export type RawThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +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, }; export type ThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +uiName: string, +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, }; +export type ResolvedThreadInfo = { + +id: string, + +type: ThreadType, + +name: ?string, + +uiName: string, + +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, +}; + export type ServerMemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, +isSender: boolean, }; export type ServerThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +depth: number, +members: $ReadOnlyArray, +roles: { [id: string]: RoleInfo }, +sourceMessageID?: string, +repliesCount: number, }; export type ThreadStore = { +threadInfos: { +[id: string]: RawThreadInfo }, }; export type RemoveThreadOperation = { +type: 'remove', +payload: { +ids: $ReadOnlyArray }, }; export type RemoveAllThreadsOperation = { +type: 'remove_all', }; export type ReplaceThreadOperation = { +type: 'replace', +payload: { +id: string, +threadInfo: RawThreadInfo }, }; export type ThreadStoreOperation = | RemoveThreadOperation | RemoveAllThreadsOperation | ReplaceThreadOperation; export type ClientDBThreadInfo = { +id: string, +type: number, +name: ?string, +description: ?string, +color: string, +creationTime: string, +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: string, +roles: string, +currentUser: string, +sourceMessageID?: string, +repliesCount: number, }; export type ClientDBReplaceThreadOperation = { +type: 'replace', +payload: ClientDBThreadInfo, }; export type ClientDBThreadStoreOperation = | RemoveThreadOperation | RemoveAllThreadsOperation | ClientDBReplaceThreadOperation; export type ThreadDeletionRequest = { +threadID: string, +accountPassword: ?string, }; export type RemoveMembersRequest = { +threadID: string, +memberIDs: $ReadOnlyArray, }; export type RoleChangeRequest = { +threadID: string, +memberIDs: $ReadOnlyArray, +role: string, }; export type ChangeThreadSettingsResult = { +threadInfo?: RawThreadInfo, +threadInfos?: { +[id: string]: RawThreadInfo }, +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, }; export type ChangeThreadSettingsPayload = { +threadID: string, +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, }; export type LeaveThreadRequest = { +threadID: string, }; export type LeaveThreadResult = { +threadInfos?: { +[id: string]: RawThreadInfo }, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type LeaveThreadPayload = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type ThreadChanges = Shape<{ +type: ThreadType, +name: string, +description: string, +color: string, +parentThreadID: ?string, +newMemberIDs: $ReadOnlyArray, }>; export type UpdateThreadRequest = { +threadID: string, +changes: ThreadChanges, }; export type BaseNewThreadRequest = { +id?: ?string, +name?: ?string, +description?: ?string, +color?: ?string, +parentThreadID?: ?string, +initialMemberIDs?: ?$ReadOnlyArray, +ghostMemberIDs?: ?$ReadOnlyArray, }; type NewThreadRequest = | { +type: 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12, ...BaseNewThreadRequest, } | { +type: 5, +sourceMessageID: string, ...BaseNewThreadRequest, }; export type ClientNewThreadRequest = { ...NewThreadRequest, +calendarQuery: CalendarQuery, }; export type ServerNewThreadRequest = { ...NewThreadRequest, +calendarQuery?: ?CalendarQuery, }; export type NewThreadResponse = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, +newThreadInfo?: RawThreadInfo, +userInfos: UserInfos, +newThreadID?: string, }; export type NewThreadResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, +userInfos: UserInfos, +newThreadID: string, }; export type ServerThreadJoinRequest = { +threadID: string, +calendarQuery?: ?CalendarQuery, }; export type ClientThreadJoinRequest = { +threadID: string, +calendarQuery: CalendarQuery, }; export type ThreadJoinResult = { threadInfos?: { +[id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, rawMessageInfos: $ReadOnlyArray, truncationStatuses: MessageTruncationStatuses, userInfos: UserInfos, rawEntryInfos?: ?$ReadOnlyArray, }; export type ThreadJoinPayload = { +updatesResult: { newUpdates: $ReadOnlyArray, }, +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: $ReadOnlyArray, }; export type SidebarInfo = { +threadInfo: ThreadInfo, +lastUpdatedTime: number, +mostRecentNonLocalMessage: ?string, }; // We can show a max of 3 sidebars inline underneath their parent in the chat // tab. If there are more, we show a button that opens a modal to see the rest export const maxReadSidebars = 3; // We can show a max of 5 sidebars inline underneath their parent // in the chat tab if every one of the displayed sidebars is unread export const maxUnreadSidebars = 5; diff --git a/lib/utils/entity-helpers.js b/lib/utils/entity-helpers.js new file mode 100644 index 000000000..a88ee3384 --- /dev/null +++ b/lib/utils/entity-helpers.js @@ -0,0 +1,73 @@ +// @flow + +import invariant from 'invariant'; +import * as React from 'react'; + +import type { ThreadInfo, ResolvedThreadInfo } from '../types/thread-types'; +import { values } from '../utils/objects'; +import { + ET, + useENSNamesForEntityText, + entityTextToRawString, +} from './entity-text'; + +function useResolvedThreadInfos( + threadInfos: $ReadOnlyArray, +): $ReadOnlyArray { + const entityText = React.useMemo( + () => + threadInfos.map(threadInfo => + ET.thread({ display: 'uiName', threadInfo }), + ), + [threadInfos], + ); + const withENSNames = useENSNamesForEntityText(entityText); + invariant( + withENSNames, + 'useENSNamesForEntityText only returns falsey when passed falsey', + ); + return React.useMemo( + () => + threadInfos.map((threadInfo, i) => { + if (typeof threadInfo.uiName === 'string') { + // Flow wants return { ...threadInfo, uiName: threadInfo.uiName } + // but that's wasteful and unneeded, so we any-cast here + return (threadInfo: any); + } + const resolvedThreadEntity = withENSNames[i]; + return { + ...threadInfo, + uiName: entityTextToRawString([resolvedThreadEntity]), + }; + }), + [threadInfos, withENSNames], + ); +} + +function useResolvedThreadInfosObj(threadInfosObj: { + +[id: string]: ThreadInfo, +}): { +[id: string]: ResolvedThreadInfo } { + const threadInfosArray = React.useMemo(() => values(threadInfosObj), [ + threadInfosObj, + ]); + const resolvedThreadInfosArray = useResolvedThreadInfos(threadInfosArray); + return React.useMemo(() => { + const obj = {}; + for (const resolvedThreadInfo of resolvedThreadInfosArray) { + obj[resolvedThreadInfo.id] = resolvedThreadInfo; + } + return obj; + }, [resolvedThreadInfosArray]); +} + +function useResolvedThreadInfo(threadInfo: ThreadInfo): ResolvedThreadInfo { + const resolutionInput = React.useMemo(() => [threadInfo], [threadInfo]); + const [resolvedThreadInfo] = useResolvedThreadInfos(resolutionInput); + return resolvedThreadInfo; +} + +export { + useResolvedThreadInfos, + useResolvedThreadInfosObj, + useResolvedThreadInfo, +};