diff --git a/lib/shared/messages/create-sidebar-message-spec.js b/lib/shared/messages/create-sidebar-message-spec.js index e1f5e441a..f6c47e5bb 100644 --- a/lib/shared/messages/create-sidebar-message-spec.js +++ b/lib/shared/messages/create-sidebar-message-spec.js @@ -1,251 +1,251 @@ // @flow import invariant from 'invariant'; import type { PlatformDetails } from '../../types/device-types'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { CreateSidebarMessageData, CreateSidebarMessageInfo, RawCreateSidebarMessageInfo, } from '../../types/messages/create-sidebar'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; import { robotextToRawString, robotextForMessageInfo, removeCreatorAsViewer, } from '../message-utils'; import { stringForUser } from '../user-utils'; import { hasMinCodeVersion } from '../version-utils'; import type { CreateMessageInfoParams, MessageSpec, MessageTitleParam, RobotextParams, } from './message-spec'; import { assertSingleMessageInfo } from './utils'; export const createSidebarMessageSpec: MessageSpec< CreateSidebarMessageData, RawCreateSidebarMessageInfo, CreateSidebarMessageInfo, > = Object.freeze({ messageContentForServerDB( data: CreateSidebarMessageData | RawCreateSidebarMessageInfo, ): string { return JSON.stringify({ ...data.initialThreadState, sourceMessageAuthorID: data.sourceMessageAuthorID, }); }, messageContentForClientDB(data: RawCreateSidebarMessageInfo): string { return this.messageContentForServerDB(data); }, messageTitle({ messageInfo, threadInfo, viewerContext, }: MessageTitleParam) { let validMessageInfo: CreateSidebarMessageInfo = (messageInfo: CreateSidebarMessageInfo); if (viewerContext === 'global_viewer') { validMessageInfo = removeCreatorAsViewer(validMessageInfo); validMessageInfo = { ...validMessageInfo, sourceMessageAuthor: { ...validMessageInfo.sourceMessageAuthor, isViewer: false, }, initialThreadState: { ...validMessageInfo.initialThreadState, otherMembers: validMessageInfo.initialThreadState.otherMembers.map( item => ({ ...item, isViewer: false, }), ), }, }; } return robotextToRawString( robotextForMessageInfo(validMessageInfo, threadInfo), ); }, rawMessageInfoFromServerDBRow(row: Object): RawCreateSidebarMessageInfo { const { sourceMessageAuthorID, ...initialThreadState } = JSON.parse( row.content, ); return { type: messageTypes.CREATE_SIDEBAR, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), sourceMessageAuthorID, initialThreadState, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawCreateSidebarMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined', ); const { sourceMessageAuthorID, ...initialThreadState } = JSON.parse( clientDBMessageInfo.content, ); const rawCreateSidebarMessageInfo: RawCreateSidebarMessageInfo = { type: messageTypes.CREATE_SIDEBAR, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, sourceMessageAuthorID: sourceMessageAuthorID, initialThreadState: initialThreadState, }; return rawCreateSidebarMessageInfo; }, createMessageInfo( rawMessageInfo: RawCreateSidebarMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ?CreateSidebarMessageInfo { const { threadInfos } = params; const parentThreadInfo = threadInfos[rawMessageInfo.initialThreadState.parentThreadID]; const sourceMessageAuthor = params.createRelativeUserInfos([ rawMessageInfo.sourceMessageAuthorID, ])[0]; if (!sourceMessageAuthor) { return null; } return { type: messageTypes.CREATE_SIDEBAR, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, sourceMessageAuthor, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, color: rawMessageInfo.initialThreadState.color, otherMembers: params.createRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), ), }, }; }, rawMessageInfoFromMessageData( messageData: CreateSidebarMessageData, id: ?string, ): RawCreateSidebarMessageInfo { invariant(id, 'RawCreateSidebarMessageInfo needs id'); return { ...messageData, id }; }, robotext( messageInfo: CreateSidebarMessageInfo, creator: string, params: RobotextParams, ): string { let text = `started ${params.encodedThreadEntity( messageInfo.threadID, - `this sidebar`, + `this thread`, )}`; const users = messageInfo.initialThreadState.otherMembers.filter( member => member.id !== messageInfo.sourceMessageAuthor.id, ); if (users.length !== 0) { const initialUsersString = params.robotextForUsers(users); text += ` and added ${initialUsersString}`; } return `${creator} ${text}`; }, shimUnsupportedMessageInfo( rawMessageInfo: RawCreateSidebarMessageInfo, platformDetails: ?PlatformDetails, ): RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo { // TODO determine min code version if (hasMinCodeVersion(platformDetails, 75)) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, - robotext: 'created a sidebar', + robotext: 'created a thread', unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo( unwrapped: RawCreateSidebarMessageInfo, ): RawCreateSidebarMessageInfo { return unwrapped; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): NotifTexts { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_SIDEBAR, 'messageInfo should be messageTypes.CREATE_SIDEBAR!', ); const prefix = stringForUser(messageInfo.creator); const title = threadInfo.uiName; const sourceMessageAuthorPossessive = messageInfo.sourceMessageAuthor .isViewer ? 'your' : `${stringForUser(messageInfo.sourceMessageAuthor)}'s`; const body = - `started a sidebar in response to ${sourceMessageAuthorPossessive} ` + + `started a thread in response to ${sourceMessageAuthorPossessive} ` + `message "${messageInfo.initialThreadState.name ?? ''}"`; const merged = `${prefix} ${body}`; return { merged, body, title, prefix, }; }, generatesNotifs: true, userIDs(rawMessageInfo: RawCreateSidebarMessageInfo): $ReadOnlyArray { return rawMessageInfo.initialThreadState.memberIDs; }, threadIDs( rawMessageInfo: RawCreateSidebarMessageInfo, ): $ReadOnlyArray { const { parentThreadID } = rawMessageInfo.initialThreadState; return [parentThreadID]; }, }); diff --git a/lib/shared/messages/create-sub-thread-message-spec.js b/lib/shared/messages/create-sub-thread-message-spec.js index 83e9280ac..dc401f84b 100644 --- a/lib/shared/messages/create-sub-thread-message-spec.js +++ b/lib/shared/messages/create-sub-thread-message-spec.js @@ -1,169 +1,169 @@ // @flow import invariant from 'invariant'; import { permissionLookup } from '../../permissions/thread-permissions'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { CreateSubthreadMessageData, CreateSubthreadMessageInfo, RawCreateSubthreadMessageInfo, } from '../../types/messages/create-subthread'; import type { NotifTexts } from '../../types/notif-types'; import { threadPermissions, threadTypes } from '../../types/thread-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; import { robotextToRawString, robotextForMessageInfo, removeCreatorAsViewer, } from '../message-utils'; import type { CreateMessageInfoParams, MessageSpec, MessageTitleParam, NotificationTextsParams, } from './message-spec'; import { assertSingleMessageInfo } from './utils'; export const createSubThreadMessageSpec: MessageSpec< CreateSubthreadMessageData, RawCreateSubthreadMessageInfo, CreateSubthreadMessageInfo, > = Object.freeze({ messageContentForServerDB( data: CreateSubthreadMessageData | RawCreateSubthreadMessageInfo, ): string { return data.childThreadID; }, messageContentForClientDB(data: RawCreateSubthreadMessageInfo): string { return this.messageContentForServerDB(data); }, messageTitle({ messageInfo, threadInfo, viewerContext, }: MessageTitleParam) { let validMessageInfo: CreateSubthreadMessageInfo = (messageInfo: CreateSubthreadMessageInfo); if (viewerContext === 'global_viewer') { validMessageInfo = removeCreatorAsViewer(validMessageInfo); } return robotextToRawString( robotextForMessageInfo(validMessageInfo, threadInfo), ); }, rawMessageInfoFromServerDBRow(row: Object): ?RawCreateSubthreadMessageInfo { const subthreadPermissions = row.subthread_permissions; if (!permissionLookup(subthreadPermissions, threadPermissions.KNOW_OF)) { return null; } return { type: messageTypes.CREATE_SUB_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), childThreadID: row.content, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawCreateSubthreadMessageInfo { const content = clientDBMessageInfo.content; invariant( content !== undefined && content !== null, 'content must be defined', ); const rawCreateSubthreadMessageInfo: RawCreateSubthreadMessageInfo = { type: messageTypes.CREATE_SUB_THREAD, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, childThreadID: content, }; return rawCreateSubthreadMessageInfo; }, createMessageInfo( rawMessageInfo: RawCreateSubthreadMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ?CreateSubthreadMessageInfo { const { threadInfos } = params; const childThreadInfo = threadInfos[rawMessageInfo.childThreadID]; if (!childThreadInfo) { return null; } return { type: messageTypes.CREATE_SUB_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, childThreadInfo, }; }, rawMessageInfoFromMessageData( messageData: CreateSubthreadMessageData, id: ?string, ): RawCreateSubthreadMessageInfo { invariant(id, 'RawCreateSubthreadMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: CreateSubthreadMessageInfo, creator: string): string { const childName = messageInfo.childThreadInfo.name; const childNoun = messageInfo.childThreadInfo.type === threadTypes.SIDEBAR - ? 'sidebar' + ? 'thread' : 'subchannel'; if (childName) { return ( `${creator} created a ${childNoun}` + ` named "<${encodeURI(childName)}|t${messageInfo.childThreadInfo.id}>"` ); } else { return ( `${creator} created a ` + `<${childNoun}|t${messageInfo.childThreadInfo.id}>` ); } }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_SUB_THREAD, 'messageInfo should be messageTypes.CREATE_SUB_THREAD!', ); return params.notifTextForSubthreadCreation( messageInfo.creator, messageInfo.childThreadInfo.type, threadInfo, messageInfo.childThreadInfo.name, messageInfo.childThreadInfo.uiName, ); }, generatesNotifs: true, threadIDs( rawMessageInfo: RawCreateSubthreadMessageInfo, ): $ReadOnlyArray { return [rawMessageInfo.childThreadID]; }, }); diff --git a/lib/shared/messages/sidebar-source-message-spec.js b/lib/shared/messages/sidebar-source-message-spec.js index dd782d938..6e45a3a30 100644 --- a/lib/shared/messages/sidebar-source-message-spec.js +++ b/lib/shared/messages/sidebar-source-message-spec.js @@ -1,181 +1,181 @@ // @flow import invariant from 'invariant'; import type { PlatformDetails } from '../../types/device-types'; import type { MessageInfo, RawSidebarSourceMessageInfo, SidebarSourceMessageData, SidebarSourceMessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import { messageTypes } from '../../types/message-types'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; import { hasMinCodeVersion } from '../version-utils'; import type { CreateMessageInfoParams, MessageSpec, NotificationTextsParams, RawMessageInfoFromServerDBRowParams, } from './message-spec'; import { assertSingleMessageInfo } from './utils'; export const sidebarSourceMessageSpec: MessageSpec< SidebarSourceMessageData, RawSidebarSourceMessageInfo, SidebarSourceMessageInfo, > = Object.freeze({ messageContentForServerDB( data: SidebarSourceMessageData | RawSidebarSourceMessageInfo, ): string { const sourceMessageID = data.sourceMessage?.id; invariant(sourceMessageID, 'Source message id should be set'); return JSON.stringify({ sourceMessageID, }); }, messageContentForClientDB(data: RawSidebarSourceMessageInfo): string { invariant( data.sourceMessage && data.sourceMessage.id, 'sourceMessage and sourceMessage.id should be defined', ); return JSON.stringify(data.sourceMessage); }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawSidebarSourceMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined', ); const sourceMessage = JSON.parse(clientDBMessageInfo.content); const rawSidebarSourceMessageInfo: RawSidebarSourceMessageInfo = { type: messageTypes.SIDEBAR_SOURCE, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, sourceMessage, }; return rawSidebarSourceMessageInfo; }, messageTitle() { invariant(false, 'Cannot call messageTitle on sidebarSourceMessageSpec'); }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawSidebarSourceMessageInfo { const { derivedMessages } = params; invariant(derivedMessages, 'Derived messages should be provided'); const content = JSON.parse(row.content); const sourceMessage = derivedMessages.get(content.sourceMessageID); if (!sourceMessage) { console.warn( `Message with id ${row.id} has a derived message ` + `${content.sourceMessageID} which is not present in the database`, ); } return { type: messageTypes.SIDEBAR_SOURCE, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), sourceMessage, }; }, createMessageInfo( rawMessageInfo: RawSidebarSourceMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ?SidebarSourceMessageInfo { if (!rawMessageInfo.sourceMessage) { return null; } const sourceMessage = params.createMessageInfoFromRaw( rawMessageInfo.sourceMessage, ); invariant( sourceMessage && sourceMessage.type !== messageTypes.SIDEBAR_SOURCE, 'Sidebars can not be created from SIDEBAR SOURCE', ); return { type: messageTypes.SIDEBAR_SOURCE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, sourceMessage, }; }, rawMessageInfoFromMessageData( messageData: SidebarSourceMessageData, id: ?string, ): RawSidebarSourceMessageInfo { invariant(id, 'RawSidebarSourceMessageInfo needs id'); return { ...messageData, id }; }, shimUnsupportedMessageInfo( rawMessageInfo: RawSidebarSourceMessageInfo, platformDetails: ?PlatformDetails, ): RawSidebarSourceMessageInfo | RawUnsupportedMessageInfo { // TODO determine min code version if ( hasMinCodeVersion(platformDetails, 75) && rawMessageInfo.sourceMessage ) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, - robotext: 'first message in sidebar', + robotext: 'first message in thread', dontPrefixCreator: true, unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo( unwrapped: RawSidebarSourceMessageInfo, ): RawSidebarSourceMessageInfo { return unwrapped; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.SIDEBAR_SOURCE, 'messageInfo should be messageTypes.SIDEBAR_SOURCE!', ); const sourceMessageInfo = messageInfo.sourceMessage; return params.notificationTexts([sourceMessageInfo], threadInfo); }, generatesNotifs: false, startsThread: true, }); diff --git a/lib/shared/search-utils.js b/lib/shared/search-utils.js index 4e69e3f46..f0e7b6bad 100644 --- a/lib/shared/search-utils.js +++ b/lib/shared/search-utils.js @@ -1,142 +1,142 @@ // @flow import genesis from '../facts/genesis'; import { userRelationshipStatus } from '../types/relationship-types'; import { type ThreadInfo, type ThreadType, threadTypes, threadPermissions, } from '../types/thread-types'; import type { AccountUserInfo, UserListItem } from '../types/user-types'; import SearchIndex from './search-index'; import { userIsMember, threadMemberHasPermission } from './thread-utils'; const notFriendNotice = 'not friend'; function getPotentialMemberItems( text: string, userInfos: { +[id: string]: AccountUserInfo }, searchIndex: SearchIndex, excludeUserIDs: $ReadOnlyArray, inputParentThreadInfo: ?ThreadInfo, inputCommunityThreadInfo: ?ThreadInfo, threadType: ?ThreadType, ): UserListItem[] { const communityThreadInfo = inputCommunityThreadInfo && inputCommunityThreadInfo.id !== genesis.id ? inputCommunityThreadInfo : null; const parentThreadInfo = inputParentThreadInfo && inputParentThreadInfo.id !== genesis.id ? inputParentThreadInfo : null; let results = []; const appendUserInfo = (userInfo: AccountUserInfo) => { const { id } = userInfo; if (excludeUserIDs.includes(id)) { return; } if ( communityThreadInfo && !threadMemberHasPermission( communityThreadInfo, id, threadPermissions.KNOW_OF, ) ) { return; } results.push({ ...userInfo, isMemberOfParentThread: userIsMember(parentThreadInfo, id), }); }; if (text === '') { for (const id in userInfos) { appendUserInfo(userInfos[id]); } } else { const ids = searchIndex.getSearchResults(text); for (const id of ids) { appendUserInfo(userInfos[id]); } } if (text === '') { results = results.filter(userInfo => parentThreadInfo ? userInfo.isMemberOfParentThread && userInfo.relationshipStatus !== userRelationshipStatus.BLOCKED_BY_VIEWER : userInfo.relationshipStatus === userRelationshipStatus.FRIEND, ); } const nonFriends = []; const blockedUsers = []; const friendsAndParentMembers = []; for (const userResult of results) { const relationshipStatus = userResult.relationshipStatus; if ( userResult.isMemberOfParentThread && relationshipStatus !== userRelationshipStatus.BLOCKED_BY_VIEWER ) { friendsAndParentMembers.unshift(userResult); } else if (relationshipStatus === userRelationshipStatus.FRIEND) { friendsAndParentMembers.push(userResult); } else if ( relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { blockedUsers.push(userResult); } else { nonFriends.push(userResult); } } const sortedResults = friendsAndParentMembers .concat(nonFriends) .concat(blockedUsers); return sortedResults.map( ({ isMemberOfParentThread, relationshipStatus, ...result }) => { if ( isMemberOfParentThread && relationshipStatus !== userRelationshipStatus.BLOCKED_BY_VIEWER ) { return { ...result }; } let notice, alertText, alertTitle; const userText = result.username; if (!isMemberOfParentThread && threadType === threadTypes.SIDEBAR) { notice = 'not in parent chat'; alertTitle = 'Not in parent chat'; - alertText = 'You can only add members of the parent chat to a sidebar'; + alertText = 'You can only add members of the parent chat to a thread'; } else if ( relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { notice = 'you’ve blocked this user'; alertTitle = 'Not a friend'; alertText = `Before you add ${userText} to this chat, ` + 'you’ll need to unblock them and send a friend request. ' + 'You can do this from the Block List and Friend List ' + 'in the Profile tab.'; } else if (relationshipStatus !== userRelationshipStatus.FRIEND) { notice = notFriendNotice; alertTitle = 'Not a friend'; alertText = `Before you add ${userText} to this chat, ` + 'you’ll need to send them a friend request. ' + 'You can do this from the Friend List in the Profile tab.'; } else if (parentThreadInfo) { notice = 'not in parent chat'; } return { ...result, notice, alertText, alertTitle }; }, ); } export { getPotentialMemberItems, notFriendNotice }; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index e553dcdff..99b037537 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1426 +1,1426 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find'; import * as React from 'react'; import stringHash from 'string-hash'; import tinycolor from 'tinycolor2'; import { fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from '../actions/message-actions'; import { changeThreadMemberRolesActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, } from '../actions/thread-actions'; import { searchUsers as searchUserCall } from '../actions/user-actions'; import ashoat from '../facts/ashoat'; import genesis from '../facts/genesis'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions'; import type { ChatThreadItem, ChatMessageInfoItem, } from '../selectors/chat-selectors'; import { threadSearchIndex as threadSearchIndexSelector } from '../selectors/nav-selectors'; import { threadInfoSelector, locallyUniqueToRealizedThreadIDsSelector, } from '../selectors/thread-selectors'; import { getRelativeMemberInfos, usersWithPersonalThreadSelector, } from '../selectors/user-selectors'; import type { CalendarQuery } from '../types/entry-types'; import type { RobotextMessageInfo, ComposableMessageInfo, } from '../types/message-types'; import { userRelationshipStatus } from '../types/relationship-types'; import { type RawThreadInfo, type ThreadInfo, type ThreadPermission, type MemberInfo, type ServerThreadInfo, type RelativeMemberInfo, type ThreadCurrentUserInfo, type RoleInfo, type ServerMemberInfo, type ThreadPermissionsInfo, type ThreadType, type ClientNewThreadRequest, type NewThreadResult, type ChangeThreadSettingsPayload, threadTypes, threadPermissions, threadTypeIsCommunityRoot, } from '../types/thread-types'; import { type ClientUpdateInfo, updateTypes } from '../types/update-types'; import type { GlobalAccountUserInfo, UserInfos, UserInfo, AccountUserInfo, } from '../types/user-types'; import { useDispatchActionPromise, useServerCall } from '../utils/action-utils'; import type { DispatchActionPromise } from '../utils/action-utils'; import { values } from '../utils/objects'; import { useSelector } from '../utils/redux-utils'; import { firstLine } from '../utils/string-utils'; import { pluralize, trimText } from '../utils/text-utils'; import { type ParserRules } from './markdown'; import { getMessageTitle } from './message-utils'; import { relationshipBlockedInEitherDirection } from './relationship-utils'; import threadWatcher from './thread-watcher'; function colorIsDark(color: string): boolean { return tinycolor(`#${color}`).isDark(); } const selectedThreadColorsObj = Object.freeze({ a: '4b87aa', b: '5c9f5f', c: 'b8753d', d: 'aa4b4b', e: '6d49ab', f: 'c85000', g: '008f83', h: '648caa', i: '57697f', j: '575757', }); const selectedThreadColors: $ReadOnlyArray = values( selectedThreadColorsObj, ); export type SelectedThreadColors = $Values; function generateRandomColor(): string { return selectedThreadColors[ Math.floor(Math.random() * selectedThreadColors.length) ]; } function generatePendingThreadColor(userIDs: $ReadOnlyArray): string { const ids = [...userIDs].sort().join('#'); const colorIdx = stringHash(ids) % selectedThreadColors.length; return selectedThreadColors[colorIdx]; } function threadHasPermission( threadInfo: ?(ThreadInfo | RawThreadInfo), permission: ThreadPermission, ): boolean { if (!threadInfo) { return false; } invariant( !permissionsDisabledByBlock.has(permission) || threadInfo?.uiName, `${permission} can be disabled by a block, but threadHasPermission can't ` + 'check for a block on RawThreadInfo. Please pass in ThreadInfo instead!', ); if (!threadInfo.currentUser.permissions[permission]) { return false; } return threadInfo.currentUser.permissions[permission].value; } function viewerIsMember(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function threadIsTopLevel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return threadInChatList(threadInfo) && threadIsChannel(threadInfo); } function threadIsChannel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!(threadInfo && threadInfo.type !== threadTypes.SIDEBAR); } function threadInBackgroundChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?(ThreadInfo | RawThreadInfo), userID: string, ): boolean { if (!threadInfo) { return false; } if (threadInfo.id === genesis.id) { return true; } return threadInfo.members.some(member => member.id === userID && member.role); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter(memberInfo => memberInfo.role) .map(memberInfo => memberInfo.id); } function threadIsGroupChat(threadInfo: ThreadInfo | RawThreadInfo): boolean { return ( threadInfo.members.filter(member => { if ( member.id === ashoat.id && !member.role && threadInfo.community === genesis.id ) { return false; } return member.role || member.permissions[threadPermissions.VOICED]?.value; }).length > 2 ); } function threadOrParentThreadIsGroupChat( threadInfo: RawThreadInfo | ThreadInfo, ) { return threadInfo.members.length > 2; } function threadIsPending(threadID: ?string): boolean { return !!threadID?.startsWith('pending'); } function getThreadOtherUsers( threadInfo: ThreadInfo | RawThreadInfo, viewerID: ?string, ): string[] { const otherUserIDs = []; for (const member of threadInfo.members) { if (!member.role || member.id === viewerID) { continue; } otherUserIDs.push(member.id); } return otherUserIDs; } function getSingleOtherUser( threadInfo: ThreadInfo | RawThreadInfo, viewerID: ?string, ): ?string { if (!viewerID) { return undefined; } const otherMemberIDs = getThreadOtherUsers(threadInfo, viewerID); if (otherMemberIDs.length !== 1) { return undefined; } return otherMemberIDs[0]; } function getLocallyUniqueThreadID( 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 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(); const nonViewerMembers = members ? members.filter(member => member.id !== viewerID) : []; const nonViewerMemberIDs = nonViewerMembers.map(member => member.id); const memberIDs = [...nonViewerMemberIDs, viewerID]; const threadID = getLocallyUniqueThreadID( threadType, memberIDs, sourceMessageID, ); const permissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role = { id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }; const rawThreadInfo = { id: threadID, type: threadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID(parentThreadInfo, threadType), community: getCommunity(parentThreadInfo), members: [ { id: viewerID, role: role.id, permissions: membershipPermissions, isSender: false, }, ...nonViewerMembers.map(member => ({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, })), ], roles: { [role.id]: role, }, currentUser: { role: role.id, permissions: membershipPermissions, subscription: { pushNotifs: false, home: false, }, unread: false, }, repliesCount: 0, sourceMessageID, }; const userInfos = {}; nonViewerMembers.forEach(member => (userInfos[member.id] = member)); return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } function createPendingThreadItem( viewerID: string, user: GlobalAccountUserInfo, ): ChatThreadItem { const threadInfo = createPendingThread({ viewerID, threadType: threadTypes.PERSONAL, members: [user], }); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo: { id: user.id, username: user.username, }, }; } function createPendingSidebar( sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, parentThreadInfo: ThreadInfo, viewerID: string, markdownRules: ParserRules, ): ThreadInfo { const { color, type: parentThreadType } = parentThreadInfo; const messageTitle = getMessageTitle( sourceMessageInfo, parentThreadInfo, markdownRules, 'global_viewer', ); const threadName = trimText(messageTitle, 30); const initialMembers = new Map(); if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) { const { id: sourceAuthorID, username: sourceAuthorUsername, } = sourceMessageInfo.creator; invariant( sourceAuthorUsername, 'sourceAuthorUsername should be set in createPendingSidebar', ); const initialMemberUserInfo: GlobalAccountUserInfo = { id: sourceAuthorID, username: sourceAuthorUsername, }; initialMembers.set(sourceAuthorID, initialMemberUserInfo); } const singleOtherUser = getSingleOtherUser(parentThreadInfo, viewerID); if (parentThreadType === threadTypes.PERSONAL && singleOtherUser) { const singleOtherUsername = parentThreadInfo.members.find( member => member.id === singleOtherUser, )?.username; invariant( singleOtherUsername, 'singleOtherUsername should be set in createPendingSidebar', ); const singleOtherUserInfo: GlobalAccountUserInfo = { id: singleOtherUser, username: singleOtherUsername, }; initialMembers.set(singleOtherUser, singleOtherUserInfo); } return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members: [...initialMembers.values()], parentThreadInfo, threadColor: color, name: threadName, sourceMessageID: sourceMessageInfo.id, }); } function pendingThreadType(numberOfOtherMembers: number): 4 | 6 | 7 { if (numberOfOtherMembers === 0) { return threadTypes.PRIVATE; } else if (numberOfOtherMembers === 1) { return threadTypes.PERSONAL; } else { return threadTypes.LOCAL; } } function threadTypeCanBePending(threadType: ThreadType): boolean { return ( threadType === threadTypes.PERSONAL || threadType === threadTypes.LOCAL || threadType === threadTypes.SIDEBAR || threadType === threadTypes.PRIVATE ); } type CreateRealThreadParameters = { +threadInfo: ThreadInfo, +dispatchActionPromise: DispatchActionPromise, +createNewThread: ClientNewThreadRequest => Promise, +sourceMessageID: ?string, +viewerID: ?string, +handleError?: () => mixed, +calendarQuery: CalendarQuery, }; async function createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise, createNewThread, sourceMessageID, viewerID, calendarQuery, }: CreateRealThreadParameters): Promise { if (!threadIsPending(threadInfo.id)) { return threadInfo.id; } const otherMemberIDs = getThreadOtherUsers(threadInfo, viewerID); let resultPromise; if (threadInfo.type !== threadTypes.SIDEBAR) { invariant( otherMemberIDs.length > 0, 'otherMemberIDs should not be empty for threads', ); resultPromise = createNewThread({ type: pendingThreadType(otherMemberIDs.length), initialMemberIDs: otherMemberIDs, color: threadInfo.color, calendarQuery, }); } else { invariant( sourceMessageID, 'sourceMessageID should be set when creating a sidebar', ); resultPromise = createNewThread({ type: threadTypes.SIDEBAR, initialMemberIDs: otherMemberIDs, color: threadInfo.color, sourceMessageID, parentThreadID: threadInfo.parentThreadID, name: threadInfo.name, calendarQuery, }); } dispatchActionPromise(newThreadActionTypes, resultPromise); const { newThreadID } = await resultPromise; return newThreadID; } type RawThreadInfoOptions = { +includeVisibilityRules?: ?boolean, +filterMemberList?: ?boolean, +hideThreadStructure?: ?boolean, +shimThreadTypes?: ?{ +[inType: ThreadType]: ThreadType, }, +filterDetailedThreadEditPermissions?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?RawThreadInfo { const includeVisibilityRules = options?.includeVisibilityRules; const filterMemberList = options?.filterMemberList; const hideThreadStructure = options?.hideThreadStructure; const shimThreadTypes = options?.shimThreadTypes; const filterDetailedThreadEditPermissions = options?.filterDetailedThreadEditPermissions; const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( filterMemberList && serverMember.id !== viewerID && !serverMember.role && !memberHasAdminPowers(serverMember) ) { continue; } if ( serverThreadInfo.id === genesis.id && serverMember.id !== viewerID && serverMember.id !== ashoat.id ) { continue; } const memberPermissions = filterThreadEditDetailedPermissions( serverMember.permissions, filterDetailedThreadEditPermissions, ); 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 = filterThreadEditDetailedPermissions( getAllThreadPermissions(null, serverThreadInfo.id), filterDetailedThreadEditPermissions, ); currentUser = { role: null, permissions: currentUserPermissions, subscription: { home: false, pushNotifs: false, }, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } let { type } = serverThreadInfo; if ( shimThreadTypes && shimThreadTypes[type] !== null && shimThreadTypes[type] !== undefined ) { type = shimThreadTypes[type]; } let rawThreadInfo: any = { id: serverThreadInfo.id, type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: serverThreadInfo.roles, currentUser, repliesCount: serverThreadInfo.repliesCount, }; if (!hideThreadStructure) { rawThreadInfo = { ...rawThreadInfo, containingThreadID: serverThreadInfo.containingThreadID, community: serverThreadInfo.community, }; } const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } if (includeVisibilityRules) { return { ...rawThreadInfo, visibilityRules: rawThreadInfo.type, }; } return rawThreadInfo; } function filterThreadEditDetailedPermissions( permissions: ThreadPermissionsInfo, shouldFilter: ?boolean, ): ThreadPermissionsInfo { if (!shouldFilter) { return permissions; } const { edit_thread_color, edit_thread_description, ...newPermissions } = permissions; return newPermissions; } function robotextName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { const threadUsernames = getThreadOtherUsers(threadInfo, viewerID) .map(memberID => userInfos[memberID] && userInfos[memberID].username) .filter(Boolean); if (threadUsernames.length === 0) { return 'just you'; } return pluralize(threadUsernames); } function threadUIName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { return firstLine( threadInfo.name || robotextName(threadInfo, viewerID, userInfos), ); } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { let threadInfo: ThreadInfo = { id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: threadUIName(rawThreadInfo, viewerID, userInfos), 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: getCurrentUser(rawThreadInfo, viewerID, userInfos), repliesCount: rawThreadInfo.repliesCount, }; const { sourceMessageID } = rawThreadInfo; if (sourceMessageID) { threadInfo = { ...threadInfo, sourceMessageID }; } return threadInfo; } function getCurrentUser( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } return { ...threadInfo.currentUser, permissions: { ...threadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function threadIsWithBlockedUserOnly( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( threadInfo.type !== threadTypes.PERSONAL && (threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo)) ) { return false; } const otherUserID = getSingleOtherUser(threadInfo, viewerID); if (!otherUserID) { return false; } const otherUserRelationshipStatus = userInfos[otherUserID]?.relationshipStatus; if (checkOnlyViewerBlock) { return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ); } return ( !!otherUserRelationshipStatus && relationshipBlockedInEitherDirection(otherUserRelationshipStatus) ); } function threadFrozenDueToBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true); } function rawThreadInfoFromThreadInfo(threadInfo: ThreadInfo): RawThreadInfo { let rawThreadInfo: RawThreadInfo = { id: threadInfo.id, type: threadInfo.type, name: threadInfo.name, description: threadInfo.description, color: threadInfo.color, creationTime: threadInfo.creationTime, parentThreadID: threadInfo.parentThreadID, containingThreadID: threadInfo.containingThreadID, community: threadInfo.community, members: threadInfo.members.map(relativeMemberInfo => ({ id: relativeMemberInfo.id, role: relativeMemberInfo.role, permissions: relativeMemberInfo.permissions, isSender: relativeMemberInfo.isSender, })), roles: threadInfo.roles, currentUser: threadInfo.currentUser, repliesCount: threadInfo.repliesCount, }; const { sourceMessageID } = threadInfo; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } return rawThreadInfo; } 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 usersInThreadInfo(threadInfo: RawThreadInfo | ThreadInfo): string[] { const userIDs = new Set(); for (const member of threadInfo.members) { userIDs.add(member.id); } return [...userIDs]; } function memberIsAdmin( memberInfo: RelativeMemberInfo | MemberInfo, threadInfo: ThreadInfo | RawThreadInfo, ): boolean { return !!( memberInfo.role && roleIsAdminRole(threadInfo.roles[memberInfo.role]) ); } // Since we don't have access to all of the ancestor ThreadInfos, we approximate // "parent admin" as anybody with CHANGE_ROLE permissions. function memberHasAdminPowers( memberInfo: RelativeMemberInfo | MemberInfo | ServerMemberInfo, ): boolean { return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsAdminRole(roleInfo: ?RoleInfo): boolean { return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } function threadHasAdminRole( threadInfo: ?(RawThreadInfo | ThreadInfo | ServerThreadInfo), ): boolean { if (!threadInfo) { return false; } return !!_find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: RawThreadInfo | ThreadInfo, ) { return ( threadInfo.members.filter(member => memberHasAdminPowers(member)).length > 0 ); } function identifyInvalidatedThreads( updateInfos: $ReadOnlyArray, ): Set { const invalidated = new Set(); for (const updateInfo of updateInfos) { if (updateInfo.type === updateTypes.DELETE_THREAD) { invalidated.add(updateInfo.threadID); } } return invalidated; } const permissionsDisabledByBlockArray = [ threadPermissions.VOICED, threadPermissions.EDIT_ENTRIES, threadPermissions.EDIT_THREAD_NAME, threadPermissions.EDIT_THREAD_COLOR, threadPermissions.EDIT_THREAD_DESCRIPTION, threadPermissions.CREATE_SUBCHANNELS, threadPermissions.CREATE_SIDEBARS, threadPermissions.JOIN_THREAD, threadPermissions.EDIT_PERMISSIONS, threadPermissions.ADD_MEMBERS, threadPermissions.REMOVE_MEMBERS, ]; const permissionsDisabledByBlock: Set = new Set( permissionsDisabledByBlockArray, ); const disabledPermissions: ThreadPermissionsInfo = permissionsDisabledByBlockArray.reduce( (permissions: ThreadPermissionsInfo, permission: string) => ({ ...permissions, [permission]: { value: false, source: null }, }), {}, ); // Consider updating itemHeight in native/chat/chat-thread-list.react.js // if you change this const emptyItemText: string = `Background chats are just like normal chats, except they don't ` + `contribute to your unread count.\n\n` + `To move a chat over here, switch the “Background” option in its settings.`; const threadSearchText = ( threadInfo: RawThreadInfo | ThreadInfo, userInfos: UserInfos, viewerID: ?string, ): string => { const searchTextArray = []; if (threadInfo.name) { searchTextArray.push(threadInfo.name); } if (threadInfo.description) { searchTextArray.push(threadInfo.description); } for (const member of threadInfo.members) { const isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } if (member.id === viewerID) { continue; } const userInfo = userInfos[member.id]; if (userInfo && userInfo.username) { searchTextArray.push(userInfo.username); } } return searchTextArray.join(' '); }; function threadNoun(threadType: ThreadType): string { - return threadType === threadTypes.SIDEBAR ? 'sidebar' : 'chat'; + return threadType === threadTypes.SIDEBAR ? 'thread' : 'chat'; } function threadLabel(threadType: ThreadType): string { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ) { return 'Open'; } else if (threadType === threadTypes.PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { - return 'Sidebar'; + return 'Thread'; } else if (threadType === threadTypes.PRIVATE) { return 'Private'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS ) { return 'Community'; } else { return 'Secret'; } } function useWatchThread(threadInfo: ?ThreadInfo) { const dispatchActionPromise = useDispatchActionPromise(); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); const threadID = threadInfo?.id; const threadNotInChatList = !threadInChatList(threadInfo); React.useEffect(() => { if (threadID && threadNotInChatList) { threadWatcher.watchID(threadID); dispatchActionPromise( fetchMostRecentMessagesActionTypes, callFetchMostRecentMessages(threadID), ); } return () => { if (threadID && threadNotInChatList) { threadWatcher.removeID(threadID); } }; }, [ callFetchMostRecentMessages, dispatchActionPromise, threadNotInChatList, threadID, ]); } type ExistingThreadInfoFinderParams = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, }; type ExistingThreadInfoFinder = ( params: ExistingThreadInfoFinderParams, ) => ?ThreadInfo; function useExistingThreadInfoFinder( baseThreadInfo: ?ThreadInfo, ): ExistingThreadInfoFinder { const threadInfos = useSelector(threadInfoSelector); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const userInfos = useSelector(state => state.userStore.userInfos); const locallyUniqueToRealizedThreadIDs = useSelector(state => locallyUniqueToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); return React.useCallback( (params: ExistingThreadInfoFinderParams): ?ThreadInfo => { if (!baseThreadInfo) { return null; } const realizedThreadInfo = threadInfos[baseThreadInfo.id]; if (realizedThreadInfo) { return realizedThreadInfo; } if (!viewerID || !threadIsPending(baseThreadInfo.id)) { return baseThreadInfo; } 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 ? getLocallyUniqueThreadID( pendingThreadType(userInfoInputArray.length), [...userInfoInputArray.map(user => user.id), viewerID], sourceMessageID, ) : getLocallyUniqueThreadID( baseThreadInfo.type, baseThreadInfo.members.map(member => member.id), sourceMessageID, ); const realizedThreadID = locallyUniqueToRealizedThreadIDs.get( pendingThreadID, ); if (realizedThreadID && threadInfos[realizedThreadID]) { return threadInfos[realizedThreadID]; } const updatedThread = searching ? createPendingThread({ viewerID, threadType: pendingThreadType(userInfoInputArray.length), members: userInfoInputArray, }) : baseThreadInfo; return { ...updatedThread, currentUser: getCurrentUser(updatedThread, viewerID, userInfos), }; }, [ baseThreadInfo, threadInfos, viewerID, locallyUniqueToRealizedThreadIDs, userInfos, ], ); } type ThreadTypeParentRequirement = 'optional' | 'required' | 'disabled'; function getThreadTypeParentRequirement( threadType: ThreadType, ): ThreadTypeParentRequirement { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || //threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.SIDEBAR ) { return 'required'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS || threadType === threadTypes.PERSONAL || threadType === threadTypes.PRIVATE ) { return 'disabled'; } else { return 'optional'; } } function threadMemberHasPermission( threadInfo: ServerThreadInfo | RawThreadInfo | ThreadInfo, memberID: string, permission: ThreadPermission, ): boolean { for (const member of threadInfo.members) { if (member.id !== memberID) { continue; } return permissionLookup(member.permissions, permission); } return false; } function useCanCreateSidebarFromMessage( threadInfo: ThreadInfo, messageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const messageCreatorUserInfo = useSelector( state => state.userStore.userInfos[messageInfo.creator.id], ); if (!messageInfo.id || threadInfo.sourceMessageID === messageInfo.id) { return false; } const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; const creatorRelationshipHasBlock = messageCreatorRelationship && relationshipBlockedInEitherDirection(messageCreatorRelationship); const hasPermission = threadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); return hasPermission && !creatorRelationshipHasBlock; } function useSidebarExistsOrCanBeCreated( threadInfo: ThreadInfo, messageItem: ChatMessageInfoItem, ): boolean { const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( threadInfo, messageItem.messageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } function checkIfDefaultMembersAreVoiced(threadInfo: ThreadInfo): boolean { const defaultRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = threadInfo.roles[defaultRoleID]; return !!defaultRole.permissions[threadPermissions.VOICED]; } function draftKeyFromThreadID(threadID: string): string { return `${threadID}/message_composer`; } function getContainingThreadID( parentThreadInfo: ?ServerThreadInfo | 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 | 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, viewerID: ?string, ): $ReadOnlyArray { if (!searchText) { return chatListData.filter( item => threadIsTopLevel(item.threadInfo) && threadFilter(item.threadInfo), ); } const privateThreads = []; const personalThreads = []; const otherThreads = []; for (const item of chatListData) { if (!threadSearchResults.has(item.threadInfo.id)) { continue; } if (item.threadInfo.type === threadTypes.PRIVATE) { privateThreads.push({ ...item, sidebars: [] }); } else if (item.threadInfo.type === threadTypes.PERSONAL) { personalThreads.push({ ...item, sidebars: [] }); } else { otherThreads.push({ ...item, sidebars: [] }); } } const chatItems = [...privateThreads, ...personalThreads, ...otherThreads]; if (viewerID) { chatItems.push( ...usersSearchResults.map(user => createPendingThreadItem(viewerID, user), ), ); } return chatItems; } type ThreadListSearchResult = { +threadSearchResults: $ReadOnlySet, +usersSearchResults: $ReadOnlyArray, }; function useThreadListSearch( chatListData: $ReadOnlyArray, searchText: string, viewerID: ?string, ): ThreadListSearchResult { const callSearchUsers = useServerCall(searchUserCall); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); const searchUsers = React.useCallback( async (usernamePrefix: string) => { if (usernamePrefix.length === 0) { return []; } const { userInfos } = await callSearchUsers(usernamePrefix); return userInfos.filter( info => !usersWithPersonalThread.has(info.id) && info.id !== viewerID, ); }, [callSearchUsers, usersWithPersonalThread, viewerID], ); const [threadSearchResults, setThreadSearchResults] = React.useState( new Set(), ); const [usersSearchResults, setUsersSearchResults] = React.useState([]); const threadSearchIndex = useSelector(threadSearchIndexSelector); React.useEffect(() => { (async () => { const results = threadSearchIndex.getSearchResults(searchText); setThreadSearchResults(new Set(results)); const usersResults = await searchUsers(searchText); setUsersSearchResults(usersResults); })(); }, [searchText, chatListData, threadSearchIndex, searchUsers]); return { threadSearchResults, usersSearchResults }; } function removeMemberFromThread( threadInfo: ThreadInfo, memberInfo: RelativeMemberInfo, dispatchActionPromise: DispatchActionPromise, removeUserFromThreadServerCall: ( threadID: string, memberIDs: $ReadOnlyArray, ) => Promise, ) { const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`; dispatchActionPromise( removeUsersFromThreadActionTypes, removeUserFromThreadServerCall(threadInfo.id, [memberInfo.id]), { customKeyName }, ); } function switchMemberAdminRoleInThread( threadInfo: ThreadInfo, memberInfo: RelativeMemberInfo, isCurrentlyAdmin: boolean, dispatchActionPromise: DispatchActionPromise, changeUserRoleServerCall: ( threadID: string, memberIDs: $ReadOnlyArray, newRole: string, ) => Promise, ) { let newRole = null; for (const roleID in threadInfo.roles) { const role = threadInfo.roles[roleID]; if (isCurrentlyAdmin && role.isDefault) { newRole = role.id; break; } else if (!isCurrentlyAdmin && roleIsAdminRole(role)) { newRole = role.id; break; } } invariant(newRole !== null, 'Could not find new role'); const customKeyName = `${changeThreadMemberRolesActionTypes.started}:${memberInfo.id}`; dispatchActionPromise( changeThreadMemberRolesActionTypes, changeUserRoleServerCall(threadInfo.id, [memberInfo.id], newRole), { customKeyName }, ); } function getAvailableThreadMemberActions( memberInfo: RelativeMemberInfo, threadInfo: ThreadInfo, canEdit: ?boolean = true, ): $ReadOnlyArray<'remove_user' | 'remove_admin' | 'make_admin'> { const role = memberInfo.role; if (!canEdit || !role) { return []; } const canRemoveMembers = threadHasPermission( threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = threadHasPermission( threadInfo, threadPermissions.CHANGE_ROLE, ); const result = []; if ( canRemoveMembers && !memberInfo.isViewer && (canChangeRoles || threadInfo.roles[role]?.isDefault) ) { result.push('remove_user'); } if (canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo)) { result.push( memberIsAdmin(memberInfo, threadInfo) ? 'remove_admin' : 'make_admin', ); } return result; } export { colorIsDark, generateRandomColor, generatePendingThreadColor, threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadIsChannel, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadIsGroupChat, threadIsPending, getSingleOtherUser, getLocallyUniqueThreadID, createPendingThread, createPendingThreadItem, createPendingSidebar, pendingThreadType, createRealThreadFromPendingThread, getCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, filterThreadEditDetailedPermissions, robotextName, threadInfoFromRawThreadInfo, rawThreadInfoFromThreadInfo, threadTypeDescriptions, usersInThreadInfo, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadSearchText, threadNoun, threadLabel, useWatchThread, useExistingThreadInfoFinder, getThreadTypeParentRequirement, threadMemberHasPermission, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, useThreadListSearch, removeMemberFromThread, switchMemberAdminRoleInThread, getAvailableThreadMemberActions, selectedThreadColors, }; diff --git a/native/chat/chat-thread-list-see-more-sidebars.react.js b/native/chat/chat-thread-list-see-more-sidebars.react.js index 716da9f20..9593ca9f4 100644 --- a/native/chat/chat-thread-list-see-more-sidebars.react.js +++ b/native/chat/chat-thread-list-see-more-sidebars.react.js @@ -1,72 +1,72 @@ // @flow import * as React from 'react'; import { Text } from 'react-native'; import Icon from 'react-native-vector-icons/Ionicons'; import type { ThreadInfo } from 'lib/types/thread-types'; import Button from '../components/button.react'; import { useColors, useStyles } from '../themes/colors'; import { sidebarHeight } from './sidebar-item.react'; type Props = { +threadInfo: ThreadInfo, +unread: boolean, +showingSidebarsInline: boolean, +onPress: (threadInfo: ThreadInfo) => void, }; function ChatThreadListSeeMoreSidebars(props: Props): React.Node { const { onPress, threadInfo, unread, showingSidebarsInline } = props; const onPressButton = React.useCallback(() => onPress(threadInfo), [ onPress, threadInfo, ]); const colors = useColors(); const styles = useStyles(unboundStyles); const unreadStyle = unread ? styles.unread : null; - const buttonText = showingSidebarsInline ? 'See more...' : 'See sidebars...'; + const buttonText = showingSidebarsInline ? 'See more...' : 'See threads...'; return ( ); } const unboundStyles = { unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, button: { height: sidebarHeight, flexDirection: 'row', display: 'flex', paddingLeft: 28, paddingRight: 18, alignItems: 'center', backgroundColor: 'listBackground', }, icon: { paddingLeft: 5, color: 'listForegroundSecondaryLabel', width: 35, }, text: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 16, paddingLeft: 3, paddingBottom: 2, }, }; export default ChatThreadListSeeMoreSidebars; diff --git a/native/chat/multimedia-message-tooltip-modal.react.js b/native/chat/multimedia-message-tooltip-modal.react.js index e48c51617..f99815c27 100644 --- a/native/chat/multimedia-message-tooltip-modal.react.js +++ b/native/chat/multimedia-message-tooltip-modal.react.js @@ -1,54 +1,54 @@ // @flow import * as React from 'react'; import { displayActionResultModal } from '../navigation/action-result-modal'; import { createTooltip, tooltipHeight, type TooltipParams, type BaseTooltipProps, } from '../navigation/tooltip.react'; import type { ChatMultimediaMessageInfoItem } from '../types/chat-types'; import type { VerticalBounds } from '../types/layout-types'; import MultimediaMessageTooltipButton from './multimedia-message-tooltip-button.react'; import { navigateToSidebar } from './sidebar-navigation'; export type MultimediaMessageTooltipModalParams = TooltipParams<{ +item: ChatMultimediaMessageInfoItem, +verticalBounds: VerticalBounds, }>; const confirmReport = () => displayActionResultModal('reported to admin'); const spec = { entries: [ { id: 'create_sidebar', - text: 'Create sidebar', + text: 'Create thread', onPress: navigateToSidebar, }, { id: 'open_sidebar', - text: 'Go to sidebar', + text: 'Go to thread', onPress: navigateToSidebar, }, { id: 'report', text: 'Report', onPress: confirmReport, }, ], }; const MultimediaMessageTooltipModal: React.ComponentType< BaseTooltipProps<'MultimediaMessageTooltipModal'>, > = createTooltip<'MultimediaMessageTooltipModal'>( MultimediaMessageTooltipButton, spec, ); const multimediaMessageTooltipHeight: number = tooltipHeight( spec.entries.length, ); export { MultimediaMessageTooltipModal, multimediaMessageTooltipHeight }; diff --git a/native/chat/robotext-message-tooltip-modal.react.js b/native/chat/robotext-message-tooltip-modal.react.js index 935d03c80..06598c225 100644 --- a/native/chat/robotext-message-tooltip-modal.react.js +++ b/native/chat/robotext-message-tooltip-modal.react.js @@ -1,43 +1,43 @@ // @flow import * as React from 'react'; import { createTooltip, tooltipHeight, type TooltipParams, type BaseTooltipProps, } from '../navigation/tooltip.react'; import type { ChatRobotextMessageInfoItemWithHeight } from '../types/chat-types'; import RobotextMessageTooltipButton from './robotext-message-tooltip-button.react'; import { navigateToSidebar } from './sidebar-navigation'; export type RobotextMessageTooltipModalParams = TooltipParams<{ +item: ChatRobotextMessageInfoItemWithHeight, }>; const spec = { entries: [ { id: 'create_sidebar', - text: 'Create sidebar', + text: 'Create thread', onPress: navigateToSidebar, }, { id: 'open_sidebar', - text: 'Go to sidebar', + text: 'Go to thread', onPress: navigateToSidebar, }, ], }; const RobotextMessageTooltipModal: React.ComponentType< BaseTooltipProps<'RobotextMessageTooltipModal'>, > = createTooltip<'RobotextMessageTooltipModal'>( RobotextMessageTooltipButton, spec, ); const robotextMessageTooltipHeight: number = tooltipHeight(spec.entries.length); export { RobotextMessageTooltipModal, robotextMessageTooltipHeight }; diff --git a/native/chat/settings/thread-settings-promote-sidebar.react.js b/native/chat/settings/thread-settings-promote-sidebar.react.js index 6ddf44139..81914d0d1 100644 --- a/native/chat/settings/thread-settings-promote-sidebar.react.js +++ b/native/chat/settings/thread-settings-promote-sidebar.react.js @@ -1,114 +1,114 @@ // @flow import * as React from 'react'; import { Text, ActivityIndicator, View, Alert } from 'react-native'; import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import Button from '../../components/button.react'; import { type Colors, useColors, useStyles } from '../../themes/colors'; import type { ViewStyle } from '../../types/styles'; type BaseProps = { +threadInfo: ThreadInfo, +buttonStyle: ViewStyle, }; type Props = { ...BaseProps, +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, +promoteSidebar: () => mixed, }; class ThreadSettingsPromoteSidebar extends React.PureComponent { onClick = () => { Alert.alert( 'Are you sure?', - 'Promoting a sidebar to a channel cannot be undone.', + 'Promoting a thread to a channel cannot be undone.', [ { text: 'Cancel', style: 'cancel', }, { text: 'Yes', onPress: this.props.promoteSidebar, }, ], ); }; render() { const { panelIosHighlightUnderlay, panelForegroundSecondaryLabel, } = this.props.colors; const loadingIndicator = this.props.loadingStatus === 'loading' ? ( ) : null; return ( ); } } const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, }, }; const onError = () => { Alert.alert('Unknown error', 'Uhh... try again?', undefined, { cancelable: true, }); }; const ConnectedThreadSettingsPromoteSidebar: React.ComponentType = React.memo( function ConnectedThreadSettingsPromoteSidebar(props: BaseProps) { const { threadInfo } = props; const colors = useColors(); const styles = useStyles(unboundStyles); const { onPromoteSidebar, loading } = usePromoteSidebar( threadInfo, onError, ); return ( ); }, ); export default ConnectedThreadSettingsPromoteSidebar; diff --git a/native/chat/settings/thread-settings.react.js b/native/chat/settings/thread-settings.react.js index decfdf5b2..218fac2e7 100644 --- a/native/chat/settings/thread-settings.react.js +++ b/native/chat/settings/thread-settings.react.js @@ -1,1153 +1,1153 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, FlatList, Platform } from 'react-native'; import { createSelector } from 'reselect'; import tinycolor from 'tinycolor2'; import { changeThreadSettingsActionTypes, leaveThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions'; import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector, childThreadInfos, } from 'lib/selectors/thread-selectors'; import { getAvailableRelationshipButtons } from 'lib/shared/relationship-utils'; import { threadHasPermission, viewerIsMember, threadInChatList, getSingleOtherUser, threadIsChannel, } from 'lib/shared/thread-utils'; import threadWatcher from 'lib/shared/thread-watcher'; import type { RelationshipButton } from 'lib/types/relationship-types'; import { type ThreadInfo, type RelativeMemberInfo, threadPermissions, threadTypes, } from 'lib/types/thread-types'; import type { UserInfos } from 'lib/types/user-types'; import ThreadAncestors from '../../components/thread-ancestors.react'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state'; import type { TabNavigationProp } from '../../navigation/app-navigator.react'; import { defaultStackScreenOptions } from '../../navigation/options'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context'; import type { NavigationRoute } from '../../navigation/route-names'; import { AddUsersModalRouteName, ComposeSubchannelModalRouteName, } from '../../navigation/route-names'; import { useSelector } from '../../redux/redux-utils'; import type { AppState } from '../../redux/state-types'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../../themes/colors'; import type { VerticalBounds } from '../../types/layout-types'; import type { ViewStyle } from '../../types/styles'; import type { ChatNavigationProp } from '../chat.react'; import type { CategoryType } from './thread-settings-category.react'; import { ThreadSettingsCategoryHeader, ThreadSettingsCategoryFooter, } from './thread-settings-category.react'; import ThreadSettingsChildThread from './thread-settings-child-thread.react'; import ThreadSettingsColor from './thread-settings-color.react'; import ThreadSettingsDeleteThread from './thread-settings-delete-thread.react'; import ThreadSettingsDescription from './thread-settings-description.react'; import ThreadSettingsEditRelationship from './thread-settings-edit-relationship.react'; import ThreadSettingsHomeNotifs from './thread-settings-home-notifs.react'; import ThreadSettingsLeaveThread from './thread-settings-leave-thread.react'; import { ThreadSettingsSeeMore, ThreadSettingsAddMember, ThreadSettingsAddSubchannel, } from './thread-settings-list-action.react'; import ThreadSettingsMember from './thread-settings-member.react'; import ThreadSettingsName from './thread-settings-name.react'; import ThreadSettingsParent from './thread-settings-parent.react'; import ThreadSettingsPromoteSidebar from './thread-settings-promote-sidebar.react'; import ThreadSettingsPushNotifs from './thread-settings-push-notifs.react'; import ThreadSettingsVisibility from './thread-settings-visibility.react'; const itemPageLength = 5; export type ThreadSettingsParams = { +threadInfo: ThreadInfo, }; export type ThreadSettingsNavigate = $PropertyType< ChatNavigationProp<'ThreadSettings'>, 'navigate', >; type ChatSettingsItem = | { +itemType: 'header', +key: string, +title: string, +categoryType: CategoryType, } | { +itemType: 'footer', +key: string, +categoryType: CategoryType, } | { +itemType: 'name', +key: string, +threadInfo: ThreadInfo, +nameEditValue: ?string, +canChangeSettings: boolean, } | { +itemType: 'color', +key: string, +threadInfo: ThreadInfo, +colorEditValue: string, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, } | { +itemType: 'description', +key: string, +threadInfo: ThreadInfo, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +canChangeSettings: boolean, } | { +itemType: 'parent', +key: string, +threadInfo: ThreadInfo, +parentThreadInfo: ?ThreadInfo, } | { +itemType: 'visibility', +key: string, +threadInfo: ThreadInfo, } | { +itemType: 'pushNotifs', +key: string, +threadInfo: ThreadInfo, } | { +itemType: 'homeNotifs', +key: string, +threadInfo: ThreadInfo, } | { +itemType: 'seeMore', +key: string, +onPress: () => void, } | { +itemType: 'childThread', +key: string, +threadInfo: ThreadInfo, +firstListItem: boolean, +lastListItem: boolean, } | { +itemType: 'addSubchannel', +key: string, } | { +itemType: 'member', +key: string, +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, } | { +itemType: 'addMember', +key: string, } | { +itemType: 'promoteSidebar' | 'leaveThread' | 'deleteThread', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, } | { +itemType: 'editRelationship', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, +relationshipButton: RelationshipButton, }; type BaseProps = { +navigation: ChatNavigationProp<'ThreadSettings'>, +route: NavigationRoute<'ThreadSettings'>, }; type Props = { ...BaseProps, // Redux state +userInfos: UserInfos, +viewerID: ?string, +threadInfo: ?ThreadInfo, +parentThreadInfo: ?ThreadInfo, +childThreadInfos: ?$ReadOnlyArray, +somethingIsSaving: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, +canPromoteSidebar: boolean, }; type State = { +numMembersShowing: number, +numSubchannelsShowing: number, +numSidebarsShowing: number, +nameEditValue: ?string, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +colorEditValue: string, +verticalBounds: ?VerticalBounds, }; type PropsAndState = { ...Props, ...State }; class ThreadSettings extends React.PureComponent { flatListContainer: ?React.ElementRef; constructor(props: Props) { super(props); const threadInfo = props.threadInfo; invariant(threadInfo, 'ThreadInfo should exist when ThreadSettings opened'); this.state = { numMembersShowing: itemPageLength, numSubchannelsShowing: itemPageLength, numSidebarsShowing: itemPageLength, nameEditValue: null, descriptionEditValue: null, descriptionTextHeight: null, colorEditValue: threadInfo.color, verticalBounds: null, }; } static getThreadInfo(props: { threadInfo: ?ThreadInfo, route: NavigationRoute<'ThreadSettings'>, ... }): ThreadInfo { const { threadInfo } = props; if (threadInfo) { return threadInfo; } return props.route.params.threadInfo; } static scrollDisabled(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'ThreadSettings should have OverlayContext'); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidMount() { const threadInfo = ThreadSettings.getThreadInfo(this.props); if (!threadInChatList(threadInfo)) { threadWatcher.watchID(threadInfo.id); } const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const threadInfo = ThreadSettings.getThreadInfo(this.props); if (!threadInChatList(threadInfo)) { threadWatcher.removeID(threadInfo.id); } const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } onTabPress = () => { if (this.props.navigation.isFocused() && !this.props.somethingIsSaving) { this.props.navigation.popToTop(); } }; componentDidUpdate(prevProps: Props) { const oldReduxThreadInfo = prevProps.threadInfo; const newReduxThreadInfo = this.props.threadInfo; if (newReduxThreadInfo && newReduxThreadInfo !== oldReduxThreadInfo) { this.props.navigation.setParams({ threadInfo: newReduxThreadInfo }); } const oldNavThreadInfo = ThreadSettings.getThreadInfo(prevProps); const newNavThreadInfo = ThreadSettings.getThreadInfo(this.props); if (oldNavThreadInfo.id !== newNavThreadInfo.id) { if (!threadInChatList(oldNavThreadInfo)) { threadWatcher.removeID(oldNavThreadInfo.id); } if (!threadInChatList(newNavThreadInfo)) { threadWatcher.watchID(newNavThreadInfo.id); } } if ( !tinycolor.equals(newNavThreadInfo.color, oldNavThreadInfo.color) && tinycolor.equals(this.state.colorEditValue, oldNavThreadInfo.color) ) { this.setState({ colorEditValue: newNavThreadInfo.color }); } if (defaultStackScreenOptions.gestureEnabled) { const scrollIsDisabled = ThreadSettings.scrollDisabled(this.props); const scrollWasDisabled = ThreadSettings.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } } threadBasicsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.nameEditValue, (propsAndState: PropsAndState) => propsAndState.colorEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionTextHeight, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, ( threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, nameEditValue: ?string, colorEditValue: string, descriptionEditValue: ?string, descriptionTextHeight: ?number, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, ) => { const canEditThreadName = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); const canEditThreadDescription = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const canEditThreadColor = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_COLOR, ); const canChangeName = canEditThreadName && canStartEditing; const canChangeDescription = canEditThreadDescription && canStartEditing; const canChangeColor = canEditThreadColor && canStartEditing; const listData: ChatSettingsItem[] = []; listData.push({ itemType: 'header', key: 'basicsHeader', title: 'Basics', categoryType: 'full', }); listData.push({ itemType: 'name', key: 'name', threadInfo, nameEditValue, canChangeSettings: canChangeName, }); listData.push({ itemType: 'color', key: 'color', threadInfo, colorEditValue, canChangeSettings: canChangeColor, navigate, threadSettingsRouteKey: routeKey, }); listData.push({ itemType: 'footer', key: 'basicsFooter', categoryType: 'full', }); if ( (descriptionEditValue !== null && descriptionEditValue !== undefined) || threadInfo.description || canEditThreadDescription ) { listData.push({ itemType: 'description', key: 'description', threadInfo, descriptionEditValue, descriptionTextHeight, canChangeSettings: canChangeDescription, }); } const isMember = viewerIsMember(threadInfo); if (isMember) { listData.push({ itemType: 'header', key: 'subscriptionHeader', title: 'Subscription', categoryType: 'full', }); listData.push({ itemType: 'pushNotifs', key: 'pushNotifs', threadInfo, }); listData.push({ itemType: 'homeNotifs', key: 'homeNotifs', threadInfo, }); listData.push({ itemType: 'footer', key: 'subscriptionFooter', categoryType: 'full', }); } listData.push({ itemType: 'header', key: 'privacyHeader', title: 'Privacy', categoryType: 'full', }); listData.push({ itemType: 'visibility', key: 'visibility', threadInfo, }); listData.push({ itemType: 'parent', key: 'parent', threadInfo, parentThreadInfo, }); listData.push({ itemType: 'footer', key: 'privacyFooter', categoryType: 'full', }); return listData; }, ); subchannelsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSubchannelsShowing, ( threadInfo: ThreadInfo, navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSubchannelsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const subchannels = childThreads?.filter(threadIsChannel) ?? []; const canCreateSubchannels = threadHasPermission( threadInfo, threadPermissions.CREATE_SUBCHANNELS, ); if (subchannels.length === 0 && !canCreateSubchannels) { return listData; } listData.push({ itemType: 'header', key: 'subchannelHeader', title: 'Subchannels', categoryType: 'unpadded', }); if (canCreateSubchannels) { listData.push({ itemType: 'addSubchannel', key: 'addSubchannel', }); } const numItems = Math.min(numSubchannelsShowing, subchannels.length); for (let i = 0; i < numItems; i++) { const subchannelInfo = subchannels[i]; listData.push({ itemType: 'childThread', key: `childThread${subchannelInfo.id}`, threadInfo: subchannelInfo, firstListItem: i === 0 && !canCreateSubchannels, lastListItem: i === numItems - 1 && numItems === subchannels.length, }); } if (numItems < subchannels.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSubchannels', onPress: this.onPressSeeMoreSubchannels, }); } listData.push({ itemType: 'footer', key: 'subchannelFooter', categoryType: 'unpadded', }); return listData; }, ); sidebarsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSidebarsShowing, ( navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSidebarsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const sidebars = childThreads?.filter( childThreadInfo => childThreadInfo.type === threadTypes.SIDEBAR, ) ?? []; if (sidebars.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'sidebarHeader', - title: 'Sidebars', + title: 'Threads', categoryType: 'unpadded', }); const numItems = Math.min(numSidebarsShowing, sidebars.length); for (let i = 0; i < numItems; i++) { const sidebarInfo = sidebars[i]; listData.push({ itemType: 'childThread', key: `childThread${sidebarInfo.id}`, threadInfo: sidebarInfo, firstListItem: i === 0, lastListItem: i === numItems - 1 && numItems === sidebars.length, }); } if (numItems < sidebars.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSidebars', onPress: this.onPressSeeMoreSidebars, }); } listData.push({ itemType: 'footer', key: 'sidebarFooter', categoryType: 'unpadded', }); return listData; }, ); threadMembersListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, (propsAndState: PropsAndState) => propsAndState.numMembersShowing, (propsAndState: PropsAndState) => propsAndState.verticalBounds, ( threadInfo: ThreadInfo, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, numMembersShowing: number, verticalBounds: ?VerticalBounds, ) => { const listData: ChatSettingsItem[] = []; const canAddMembers = threadHasPermission( threadInfo, threadPermissions.ADD_MEMBERS, ); if (threadInfo.members.length === 0 && !canAddMembers) { return listData; } listData.push({ itemType: 'header', key: 'memberHeader', title: 'Members', categoryType: 'unpadded', }); if (canAddMembers) { listData.push({ itemType: 'addMember', key: 'addMember', }); } const numItems = Math.min(numMembersShowing, threadInfo.members.length); for (let i = 0; i < numItems; i++) { const memberInfo = threadInfo.members[i]; listData.push({ itemType: 'member', key: `member${memberInfo.id}`, memberInfo, threadInfo, canEdit: canStartEditing, navigate, firstListItem: i === 0 && !canAddMembers, lastListItem: i === numItems - 1 && numItems === threadInfo.members.length, verticalBounds, threadSettingsRouteKey: routeKey, }); } if (numItems < threadInfo.members.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreMembers', onPress: this.onPressSeeMoreMembers, }); } listData.push({ itemType: 'footer', key: 'memberFooter', categoryType: 'unpadded', }); return listData; }, ); actionsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.styles, (propsAndState: PropsAndState) => propsAndState.userInfos, (propsAndState: PropsAndState) => propsAndState.viewerID, ( threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, navigate: ThreadSettingsNavigate, styles: typeof unboundStyles, userInfos: UserInfos, viewerID: ?string, ) => { const buttons = []; if (this.props.canPromoteSidebar) { buttons.push({ itemType: 'promoteSidebar', key: 'promoteSidebar', threadInfo, navigate, }); } const canLeaveThread = threadHasPermission( threadInfo, threadPermissions.LEAVE_THREAD, ); if (viewerIsMember(threadInfo) && canLeaveThread) { buttons.push({ itemType: 'leaveThread', key: 'leaveThread', threadInfo, navigate, }); } const canDeleteThread = threadHasPermission( threadInfo, threadPermissions.DELETE_THREAD, ); if (canDeleteThread) { buttons.push({ itemType: 'deleteThread', key: 'deleteThread', threadInfo, navigate, }); } const threadIsPersonal = threadInfo.type === threadTypes.PERSONAL; if (threadIsPersonal && viewerID) { const otherMemberID = getSingleOtherUser(threadInfo, viewerID); if (otherMemberID) { const otherUserInfo = userInfos[otherMemberID]; const availableRelationshipActions = getAvailableRelationshipButtons( otherUserInfo, ); for (const action of availableRelationshipActions) { buttons.push({ itemType: 'editRelationship', key: action, threadInfo, navigate, relationshipButton: action, }); } } } const listData: ChatSettingsItem[] = []; if (buttons.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'actionsHeader', title: 'Actions', categoryType: 'unpadded', }); for (let i = 0; i < buttons.length; i++) { // Necessary for Flow... if (buttons[i].itemType === 'editRelationship') { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } else { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } } listData.push({ itemType: 'footer', key: 'actionsFooter', categoryType: 'unpadded', }); return listData; }, ); listDataSelector = createSelector( this.threadBasicsListDataSelector, this.subchannelsListDataSelector, this.sidebarsListDataSelector, this.threadMembersListDataSelector, this.actionsListDataSelector, ( threadBasicsListData: ChatSettingsItem[], subchannelsListData: ChatSettingsItem[], sidebarsListData: ChatSettingsItem[], threadMembersListData: ChatSettingsItem[], actionsListData: ChatSettingsItem[], ) => [ ...threadBasicsListData, ...subchannelsListData, ...sidebarsListData, ...threadMembersListData, ...actionsListData, ], ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { let threadAncestors; if (this.props.threadInfo) { threadAncestors = ; } return ( {threadAncestors} ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ verticalBounds: { height, y: pageY } }); }); }; renderItem = (row: { item: ChatSettingsItem, ... }) => { const item = row.item; if (item.itemType === 'header') { return ( ); } else if (item.itemType === 'footer') { return ; } else if (item.itemType === 'name') { return ( ); } else if (item.itemType === 'color') { return ( ); } else if (item.itemType === 'description') { return ( ); } else if (item.itemType === 'parent') { return ( ); } else if (item.itemType === 'visibility') { return ; } else if (item.itemType === 'pushNotifs') { return ; } else if (item.itemType === 'homeNotifs') { return ; } else if (item.itemType === 'seeMore') { return ; } else if (item.itemType === 'childThread') { return ( ); } else if (item.itemType === 'addSubchannel') { return ( ); } else if (item.itemType === 'member') { return ( ); } else if (item.itemType === 'addMember') { return ; } else if (item.itemType === 'leaveThread') { return ( ); } else if (item.itemType === 'deleteThread') { return ( ); } else if (item.itemType === 'promoteSidebar') { return ( ); } else if (item.itemType === 'editRelationship') { return ( ); } else { invariant(false, `unexpected ThreadSettings item type ${item.itemType}`); } }; setNameEditValue = (value: ?string, callback?: () => void) => { this.setState({ nameEditValue: value }, callback); }; setColorEditValue = (color: string) => { this.setState({ colorEditValue: color }); }; setDescriptionEditValue = (value: ?string, callback?: () => void) => { this.setState({ descriptionEditValue: value }, callback); }; setDescriptionTextHeight = (height: number) => { this.setState({ descriptionTextHeight: height }); }; onPressComposeSubchannel = () => { const threadInfo = ThreadSettings.getThreadInfo(this.props); this.props.navigation.navigate(ComposeSubchannelModalRouteName, { presentedFrom: this.props.route.key, threadInfo, }); }; onPressAddMember = () => { const threadInfo = ThreadSettings.getThreadInfo(this.props); this.props.navigation.navigate(AddUsersModalRouteName, { presentedFrom: this.props.route.key, threadInfo, }); }; onPressSeeMoreMembers = () => { this.setState(prevState => ({ numMembersShowing: prevState.numMembersShowing + itemPageLength, })); }; onPressSeeMoreSubchannels = () => { this.setState(prevState => ({ numSubchannelsShowing: prevState.numSubchannelsShowing + itemPageLength, })); }; onPressSeeMoreSidebars = () => { this.setState(prevState => ({ numSidebarsShowing: prevState.numSidebarsShowing + itemPageLength, })); }; } const unboundStyles = { container: { backgroundColor: 'panelBackground', flex: 1, }, flatList: { paddingVertical: 16, }, nonTopButton: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 14 : 12, }, }; const editNameLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:name`, ); const editColorLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:color`, ); const editDescriptionLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:description`, ); const leaveThreadLoadingStatusSelector = createLoadingStatusSelector( leaveThreadActionTypes, ); const somethingIsSaving = ( state: AppState, threadMembers: $ReadOnlyArray, ) => { if ( editNameLoadingStatusSelector(state) === 'loading' || editColorLoadingStatusSelector(state) === 'loading' || editDescriptionLoadingStatusSelector(state) === 'loading' || leaveThreadLoadingStatusSelector(state) === 'loading' ) { return true; } for (const threadMember of threadMembers) { const removeUserLoadingStatus = createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${threadMember.id}`, )(state); if (removeUserLoadingStatus === 'loading') { return true; } const changeRoleLoadingStatus = createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${threadMember.id}`, )(state); if (changeRoleLoadingStatus === 'loading') { return true; } } return false; }; const ConnectedThreadSettings: React.ComponentType = React.memo( function ConnectedThreadSettings(props: BaseProps) { const userInfos = useSelector(state => state.userStore.userInfos); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const threadID = props.route.params.threadInfo.id; const threadInfo: ?ThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); const parentThreadID = threadInfo ? threadInfo.parentThreadID : props.route.params.threadInfo.parentThreadID; const parentThreadInfo: ?ThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const threadMembers = React.useMemo(() => { if (!threadInfo) { return []; } return threadInfo.members; }, [threadInfo]); const boundChildThreadInfos = useSelector( state => childThreadInfos(state)[threadID], ); const boundSomethingIsSaving = useSelector(state => somethingIsSaving(state, threadMembers), ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); invariant(threadInfo, 'threadInfo must be defined'); const { canPromoteSidebar } = usePromoteSidebar(threadInfo); return ( ); }, ); export default ConnectedThreadSettings; diff --git a/native/chat/sidebar-list-modal.react.js b/native/chat/sidebar-list-modal.react.js index e684def07..2138ce26a 100644 --- a/native/chat/sidebar-list-modal.react.js +++ b/native/chat/sidebar-list-modal.react.js @@ -1,118 +1,118 @@ // @flow import * as React from 'react'; import { TextInput, FlatList, StyleSheet } from 'react-native'; import { useSearchSidebars } from 'lib/hooks/search-sidebars'; import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types'; import Modal from '../components/modal.react'; import Search from '../components/search.react'; import type { RootNavigationProp } from '../navigation/root-navigator.react'; import type { NavigationRoute } from '../navigation/route-names'; import { useIndicatorStyle } from '../themes/colors'; import { waitForModalInputFocus } from '../utils/timers'; import { useNavigateToThread } from './message-list-types'; import { SidebarItem } from './sidebar-item.react'; export type SidebarListModalParams = { +threadInfo: ThreadInfo, }; function keyExtractor(sidebarInfo: SidebarInfo) { return sidebarInfo.threadInfo.id; } function getItemLayout(data: ?$ReadOnlyArray, index: number) { return { length: 24, offset: 24 * index, index }; } type Props = { +navigation: RootNavigationProp<'SidebarListModal'>, +route: NavigationRoute<'SidebarListModal'>, }; function SidebarListModal(props: Props): React.Node { const { listData, searchState, setSearchState, onChangeSearchInputText, } = useSearchSidebars(props.route.params.threadInfo); const searchTextInputRef = React.useRef(); const setSearchTextInputRef = React.useCallback( async (textInput: ?React.ElementRef) => { searchTextInputRef.current = textInput; if (!textInput) { return; } await waitForModalInputFocus(); if (searchTextInputRef.current) { searchTextInputRef.current.focus(); } }, [], ); const navigateToThread = useNavigateToThread(); const onPressItem = React.useCallback( (threadInfo: ThreadInfo) => { setSearchState({ text: '', results: new Set(), }); if (searchTextInputRef.current) { searchTextInputRef.current.blur(); } navigateToThread({ threadInfo }); }, [navigateToThread, setSearchState], ); const renderItem = React.useCallback( (row: { item: SidebarInfo, ... }) => { return ( ); }, [onPressItem], ); const indicatorStyle = useIndicatorStyle(); return ( ); } const styles = StyleSheet.create({ search: { marginBottom: 8, }, sidebar: { backgroundColor: 'transparent', paddingLeft: 0, paddingRight: 5, }, }); export default SidebarListModal; diff --git a/native/chat/text-message-tooltip-modal.react.js b/native/chat/text-message-tooltip-modal.react.js index c5303135e..6c240006c 100644 --- a/native/chat/text-message-tooltip-modal.react.js +++ b/native/chat/text-message-tooltip-modal.react.js @@ -1,76 +1,76 @@ // @flow import Clipboard from '@react-native-community/clipboard'; import invariant from 'invariant'; import * as React from 'react'; import { createMessageReply } from 'lib/shared/message-utils'; import type { DispatchFunctions, ActionFunc } from 'lib/utils/action-utils'; import type { InputState } from '../input/input-state'; import { displayActionResultModal } from '../navigation/action-result-modal'; import { createTooltip, tooltipHeight, type TooltipParams, type TooltipRoute, type BaseTooltipProps, } from '../navigation/tooltip.react'; import type { ChatTextMessageInfoItemWithHeight } from '../types/chat-types'; import { navigateToSidebar } from './sidebar-navigation'; import TextMessageTooltipButton from './text-message-tooltip-button.react'; export type TextMessageTooltipModalParams = TooltipParams<{ +item: ChatTextMessageInfoItemWithHeight, }>; const confirmCopy = () => displayActionResultModal('copied!'); const confirmReport = () => displayActionResultModal('reported to admin'); function onPressCopy(route: TooltipRoute<'TextMessageTooltipModal'>) { Clipboard.setString(route.params.item.messageInfo.text); setTimeout(confirmCopy); } function onPressReply( route: TooltipRoute<'TextMessageTooltipModal'>, dispatchFunctions: DispatchFunctions, bindServerCall: (serverCall: ActionFunc) => F, inputState: ?InputState, ) { invariant( inputState, 'inputState should be set in TextMessageTooltipModal.onPressReply', ); inputState.addReply(createMessageReply(route.params.item.messageInfo.text)); } const spec = { entries: [ { id: 'copy', text: 'Copy', onPress: onPressCopy }, { id: 'reply', text: 'Reply', onPress: onPressReply }, { id: 'report', text: 'Report', onPress: confirmReport, }, { id: 'create_sidebar', - text: 'Create sidebar', + text: 'Create thread', onPress: navigateToSidebar, }, { id: 'open_sidebar', - text: 'Go to sidebar', + text: 'Go to thread', onPress: navigateToSidebar, }, ], }; const TextMessageTooltipModal: React.ComponentType< BaseTooltipProps<'TextMessageTooltipModal'>, > = createTooltip<'TextMessageTooltipModal'>(TextMessageTooltipButton, spec); const textMessageTooltipHeight: number = tooltipHeight(spec.entries.length); export { TextMessageTooltipModal, textMessageTooltipHeight }; diff --git a/web/chat/chat-thread-list-see-more-sidebars.react.js b/web/chat/chat-thread-list-see-more-sidebars.react.js index 6b429fcd4..e9f83eff5 100644 --- a/web/chat/chat-thread-list-see-more-sidebars.react.js +++ b/web/chat/chat-thread-list-see-more-sidebars.react.js @@ -1,45 +1,45 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import type { ThreadInfo } from 'lib/types/thread-types'; import SidebarListModal from '../modals/chat/sidebar-list-modal.react'; import { useModalContext } from '../modals/modal-provider.react'; import css from './chat-thread-list.css'; type Props = { +threadInfo: ThreadInfo, +unread: boolean, +showingSidebarsInline: boolean, }; function ChatThreadListSeeMoreSidebars(props: Props): React.Node { const { unread, showingSidebarsInline, threadInfo } = props; const { pushModal } = useModalContext(); const onClick = React.useCallback( () => pushModal(), [pushModal, threadInfo], ); - const buttonText = showingSidebarsInline ? 'See more...' : 'See sidebars...'; + const buttonText = showingSidebarsInline ? 'See more...' : 'See threads...'; return ( ); } export default ChatThreadListSeeMoreSidebars; diff --git a/web/chat/message-tooltip.react.js b/web/chat/message-tooltip.react.js index bc2cc62d2..aece3fd61 100644 --- a/web/chat/message-tooltip.react.js +++ b/web/chat/message-tooltip.react.js @@ -1,275 +1,275 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { useSidebarExistsOrCanBeCreated } from 'lib/shared/thread-utils'; import type { ThreadInfo } from 'lib/types/thread-types'; import { longAbsoluteDate } from 'lib/utils/date-utils'; import type { InputState } from '../input/input-state'; import { useSelector } from '../redux/redux-utils'; import { useOnClickThread, useOnClickPendingSidebar, } from '../selectors/nav-selectors'; import SWMansionIcon from '../SWMansionIcon.react'; import MessageReplyButton from './message-reply-button.react'; import css from './message-tooltip.css'; import type { ItemAndContainerPositionInfo, MessagePositionInfo, OnMessagePositionWithContainerInfo, PositionInfo, } from './position-types'; import { tooltipPositions, type TooltipPosition } from './tooltip-utils'; import { TooltipMenu, type TooltipStyle, TooltipTextItem, } from './tooltip.react'; const messageActionIconExcessVerticalWhitespace = 10; -const openSidebarText = 'Go to sidebar'; -const createSidebarText = 'Create sidebar'; +const openSidebarText = 'Go to thread'; +const createSidebarText = 'Create thread'; type TooltipType = 'sidebar' | 'reply'; type MessageTooltipProps = { +threadInfo: ThreadInfo, +item: ChatMessageInfoItem, +availableTooltipPositions: $ReadOnlyArray, +setMouseOverMessagePosition?: ( messagePositionInfo: MessagePositionInfo, ) => void, +mouseOverMessagePosition: OnMessagePositionWithContainerInfo, +canReply?: boolean, +inputState?: ?InputState, }; function MessageTooltip(props: MessageTooltipProps): React.Node { const { threadInfo, item, availableTooltipPositions, setMouseOverMessagePosition, mouseOverMessagePosition, canReply, inputState, } = props; const { containerPosition } = mouseOverMessagePosition; const [activeTooltip, setActiveTooltip] = React.useState(); const [pointingTo, setPointingTo] = React.useState(); const showTooltip = React.useCallback( (tooltipType: TooltipType, iconPosition: ItemAndContainerPositionInfo) => { if (activeTooltip) { return; } setActiveTooltip(tooltipType); setPointingTo(iconPosition); }, [activeTooltip], ); const hideTooltip = React.useCallback(() => { setActiveTooltip(null); }, []); const showSidebarTooltip = React.useCallback( (event: SyntheticEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const iconPosition = getIconPosition(rect, containerPosition); showTooltip('sidebar', iconPosition); }, [containerPosition, showTooltip], ); const showReplyTooltip = React.useCallback( (event: SyntheticEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const iconPosition = getIconPosition(rect, containerPosition); showTooltip('reply', iconPosition); }, [containerPosition, showTooltip], ); const { threadCreatedFromMessage, messageInfo } = item; const onThreadOpen = useOnClickThread(threadCreatedFromMessage); const onPendingSidebarOpen = useOnClickPendingSidebar( messageInfo, threadInfo, ); const onSidebarButtonClick = React.useCallback( (event: SyntheticEvent) => { if (threadCreatedFromMessage) { onThreadOpen(event); } else { onPendingSidebarOpen(event); } }, [onPendingSidebarOpen, onThreadOpen, threadCreatedFromMessage], ); const onReplyButtonClick = React.useCallback(() => { invariant( setMouseOverMessagePosition, 'setMouseOverMessagePosition should be set if replyButton exists', ); setMouseOverMessagePosition({ type: 'off', item: item }); }, [item, setMouseOverMessagePosition]); let tooltipText = ''; if (activeTooltip === 'reply') { tooltipText = 'Reply'; } else if (activeTooltip === 'sidebar') { tooltipText = threadCreatedFromMessage ? openSidebarText : createSidebarText; } let tooltipMenu = null; if (pointingTo && activeTooltip) { tooltipMenu = ( ); } let replyButton; if (canReply) { invariant(inputState, 'inputState must be set if replyButton exists'); invariant( mouseOverMessagePosition, 'mouseOverMessagePosition must be set if replyButton exists', ); replyButton = (
{activeTooltip === 'reply' ? tooltipMenu : null}
); } const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( threadInfo, item, ); let sidebarButton; if (sidebarExistsOrCanBeCreated) { sidebarButton = (
{activeTooltip === 'sidebar' ? tooltipMenu : null}
); } const timezone = useSelector(state => state.timeZone); const timestampText = React.useMemo( () => longAbsoluteDate(messageInfo.time, timezone), [messageInfo.time, timezone], ); const { isViewer } = messageInfo.creator; const messageActionButtonsContainerClassName = classNames({ [css.messageActionContainer]: true, [css.messageActionButtons]: true, [css.messageActionButtonsViewer]: isViewer, [css.messageActionButtonsNonViewer]: !isViewer, }); return (
{sidebarButton} {replyButton}

{timestampText}

); } function getIconPosition( rect: ClientRect, containerPosition: PositionInfo, ): ItemAndContainerPositionInfo { const { top, bottom, left, right, width, height } = rect; return { containerPosition, itemPosition: { top: top - containerPosition.top + messageActionIconExcessVerticalWhitespace, bottom: bottom - containerPosition.top - messageActionIconExcessVerticalWhitespace, left: left - containerPosition.left, right: right - containerPosition.left, width, height: height - messageActionIconExcessVerticalWhitespace * 2, }, }; } function getMessageActionTooltipStyle( tooltipPosition: TooltipPosition, ): TooltipStyle { let className; if (tooltipPosition === tooltipPositions.TOP_RIGHT) { className = classNames( css.messageActionTopRightTooltip, css.messageActionExtraAreaTop, css.messageActionExtraAreaTopRight, ); } else if (tooltipPosition === tooltipPositions.TOP_LEFT) { className = classNames( css.messageActionTopLeftTooltip, css.messageActionExtraAreaTop, css.messageActionExtraAreaTopLeft, ); } else if (tooltipPosition === tooltipPositions.RIGHT) { className = classNames( css.messageActionRightTooltip, css.messageActionExtraArea, css.messageActionExtraAreaRight, ); } else if (tooltipPosition === tooltipPositions.LEFT) { className = classNames( css.messageActionLeftTooltip, css.messageActionExtraArea, css.messageActionExtraAreaLeft, ); } invariant(className, `${tooltipPosition} is not valid for message tooltip`); return { className }; } export default MessageTooltip; diff --git a/web/chat/sidebar-item.react.js b/web/chat/sidebar-item.react.js index bf0eb98d7..6d89ab6b2 100644 --- a/web/chat/sidebar-item.react.js +++ b/web/chat/sidebar-item.react.js @@ -1,55 +1,55 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import type { SidebarInfo } from 'lib/types/thread-types'; import { useOnClickThread } from '../selectors/nav-selectors'; import css from './chat-thread-list.css'; type Props = { +sidebarInfo: SidebarInfo, +extendArrow?: boolean, }; function SidebarItem(props: Props): React.Node { const { sidebarInfo: { threadInfo }, extendArrow = false, } = props; const { currentUser: { unread }, } = threadInfo; const onClick = useOnClickThread(threadInfo); const unreadCls = classNames(css.sidebarTitle, { [css.unread]: unread }); let arrow; if (extendArrow) { arrow = ( sidebar arrow ); } else { arrow = ( - sidebar arrow + thread arrow ); } return ( <> {arrow}
{threadInfo.uiName}
); } export default SidebarItem; diff --git a/web/chat/thread-menu.react.js b/web/chat/thread-menu.react.js index 9bae8de29..d2a99f214 100644 --- a/web/chat/thread-menu.react.js +++ b/web/chat/thread-menu.react.js @@ -1,280 +1,280 @@ // @flow import * as React from 'react'; import { leaveThread, leaveThreadActionTypes, } from 'lib/actions/thread-actions'; import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react'; import { childThreadInfos } from 'lib/selectors/thread-selectors'; import { threadHasPermission, viewerIsMember, threadIsChannel, } from 'lib/shared/thread-utils'; import { type ThreadInfo, threadTypes, threadPermissions, } from 'lib/types/thread-types'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import MenuItem from '../components/menu-item.react'; import Menu from '../components/menu.react'; import SidebarListModal from '../modals/chat/sidebar-list-modal.react'; import SidebarPromoteModal from '../modals/chat/sidebar-promote-modal.react'; import { useModalContext } from '../modals/modal-provider.react'; import ConfirmLeaveThreadModal from '../modals/threads/confirm-leave-thread-modal.react'; import ThreadMembersModal from '../modals/threads/members/members-modal.react'; import ThreadNotificationsModal from '../modals/threads/notifications/notifications-modal.react'; import ThreadSettingsModal from '../modals/threads/settings/thread-settings-modal.react'; import SubchannelsModal from '../modals/threads/subchannels/subchannels-modal.react'; import { useSelector } from '../redux/redux-utils'; import SWMansionIcon from '../SWMansionIcon.react'; import css from './thread-menu.css'; type ThreadMenuProps = { +threadInfo: ThreadInfo, }; function ThreadMenu(props: ThreadMenuProps): React.Node { const { pushModal, popModal } = useModalContext(); const { threadInfo } = props; const { onPromoteSidebar, canPromoteSidebar } = usePromoteSidebar(threadInfo); const onClickSettings = React.useCallback( () => pushModal(), [pushModal, threadInfo.id], ); const settingsItem = React.useMemo(() => { return ( ); }, [onClickSettings]); const onClickMembers = React.useCallback( () => pushModal( , ), [popModal, pushModal, threadInfo.id], ); const membersItem = React.useMemo(() => { if (threadInfo.type === threadTypes.PERSONAL) { return null; } return ( ); }, [onClickMembers, threadInfo.type]); const childThreads = useSelector( state => childThreadInfos(state)[threadInfo.id], ); const hasSidebars = React.useMemo(() => { return childThreads?.some( childThreadInfo => childThreadInfo.type === threadTypes.SIDEBAR, ); }, [childThreads]); const onClickSidebars = React.useCallback( () => pushModal(), [pushModal, threadInfo], ); const sidebarItem = React.useMemo(() => { if (!hasSidebars) { return null; } return ( ); }, [hasSidebars, onClickSidebars]); const canCreateSubchannels = React.useMemo( () => threadHasPermission(threadInfo, threadPermissions.CREATE_SUBCHANNELS), [threadInfo], ); const hasSubchannels = React.useMemo(() => { return !!childThreads?.some(threadIsChannel); }, [childThreads]); const onClickViewSubchannels = React.useCallback( () => pushModal( , ), [popModal, pushModal, threadInfo.id], ); const viewSubchannelsItem = React.useMemo(() => { if (!hasSubchannels) { return null; } return ( ); }, [hasSubchannels, onClickViewSubchannels]); const createSubchannelsItem = React.useMemo(() => { if (!canCreateSubchannels) { return null; } return ( ); }, [canCreateSubchannels]); const dispatchActionPromise = useDispatchActionPromise(); const callLeaveThread = useServerCall(leaveThread); const onConfirmLeaveThread = React.useCallback(() => { dispatchActionPromise( leaveThreadActionTypes, callLeaveThread(threadInfo.id), ); popModal(); }, [callLeaveThread, popModal, dispatchActionPromise, threadInfo.id]); const onClickLeaveThread = React.useCallback( () => pushModal( , ), [popModal, onConfirmLeaveThread, pushModal, threadInfo], ); const leaveThreadItem = React.useMemo(() => { const canLeaveThread = threadHasPermission( threadInfo, threadPermissions.LEAVE_THREAD, ); if (!viewerIsMember(threadInfo) || !canLeaveThread) { return null; } return ( ); }, [onClickLeaveThread, threadInfo]); const onClickPromoteSidebarToThread = React.useCallback( () => pushModal( , ), [pushModal, threadInfo, popModal, onPromoteSidebar], ); const promoteSidebar = React.useMemo(() => { return ( ); }, [onClickPromoteSidebarToThread]); const onClickNotifications = React.useCallback(() => { pushModal( , ); }, [popModal, pushModal, threadInfo.id]); const notificationsItem = React.useMemo(() => { if (!viewerIsMember(threadInfo)) { return null; } return ( ); }, [onClickNotifications, threadInfo]); const menuItems = React.useMemo(() => { const separator =
; // TODO: Enable menu items when the modals are implemented const SHOW_CREATE_SUBCHANNELS = false; const items = [ settingsItem, notificationsItem, membersItem, sidebarItem, viewSubchannelsItem, SHOW_CREATE_SUBCHANNELS && createSubchannelsItem, leaveThreadItem && separator, canPromoteSidebar && promoteSidebar, leaveThreadItem, ]; return items.filter(Boolean); }, [ settingsItem, notificationsItem, membersItem, sidebarItem, viewSubchannelsItem, promoteSidebar, createSubchannelsItem, leaveThreadItem, canPromoteSidebar, ]); const icon = React.useMemo( () => , [], ); return {menuItems}; } export default ThreadMenu; diff --git a/web/modals/chat/sidebar-list-modal.react.js b/web/modals/chat/sidebar-list-modal.react.js index 2635d69f7..2bc9f3ba3 100644 --- a/web/modals/chat/sidebar-list-modal.react.js +++ b/web/modals/chat/sidebar-list-modal.react.js @@ -1,96 +1,96 @@ // @flow import { faTimesCircle } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import * as React from 'react'; import { useSearchSidebars } from 'lib/hooks/search-sidebars'; import type { ThreadInfo } from 'lib/types/thread-types'; import chatThreadListCSS from '../../chat/chat-thread-list.css'; import SidebarItem from '../../chat/sidebar-item.react'; import globalCSS from '../../style.css'; import { MagnifyingGlass } from '../../vectors.react'; import Input from '../input.react'; import { useModalContext } from '../modal-provider.react'; import Modal from '../modal.react'; type Props = { +threadInfo: ThreadInfo, }; function SidebarListModal(props: Props): React.Node { const { threadInfo } = props; const { listData, searchState, clearQuery, onChangeSearchInputText, } = useSearchSidebars(threadInfo); const { popModal } = useModalContext(); const sidebars = React.useMemo( () => listData.map(item => (
)), [popModal, listData], ); let clearQueryButton = null; if (searchState.text) { clearQueryButton = ( ); } const handleOnChangeSearchText = React.useCallback( (event: SyntheticEvent) => { const { value } = event.currentTarget; onChangeSearchInputText(value); }, [onChangeSearchInputText], ); return ( - +
{clearQueryButton}
    {sidebars}
); } export default SidebarListModal; diff --git a/web/modals/chat/sidebar-promote-modal.react.js b/web/modals/chat/sidebar-promote-modal.react.js index fbc4ccd07..c6bd9b33f 100644 --- a/web/modals/chat/sidebar-promote-modal.react.js +++ b/web/modals/chat/sidebar-promote-modal.react.js @@ -1,49 +1,49 @@ // @flow import * as React from 'react'; import type { ThreadInfo } from 'lib/types/thread-types'; import Button from '../../components/button.react'; import Modal from '../modal.react'; import css from './sidebar-promote-modal.css'; type Props = { +onClose: () => void, +onConfirm: () => void, +threadInfo: ThreadInfo, }; function SidebarPromoteModal(props: Props): React.Node { const { threadInfo, onClose, onConfirm } = props; const { uiName } = threadInfo; const handleConfirm = React.useCallback(() => { onConfirm(); onClose(); }, [onClose, onConfirm]); return (

{`Are you sure you want to promote "${uiName}"?`}

-

Promoting a sidebar to a channel cannot be undone.

+

Promoting a thread to a channel cannot be undone.

); } export default SidebarPromoteModal;