diff --git a/lib/shared/sidebar-utils.js b/lib/shared/sidebar-utils.js new file mode 100644 index 000000000..f0780519a --- /dev/null +++ b/lib/shared/sidebar-utils.js @@ -0,0 +1,245 @@ +// @flow + +import invariant from 'invariant'; + +import type { ParserRules } from './markdown.js'; +import { getMessageTitle, isInvalidSidebarSource } from './message-utils.js'; +import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; +import { + createPendingThread, + getSingleOtherUser, + extractMentionedMembers, + useThreadHasPermission, + userIsMember, +} from './thread-utils.js'; +import type { ChatMessageInfoItem } from '../selectors/chat-selectors.js'; +import { messageTypes } from '../types/message-types-enum.js'; +import type { + RobotextMessageInfo, + ComposableMessageInfo, +} from '../types/message-types.js'; +import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; +import { threadPermissions } from '../types/thread-permission-types.js'; +import { threadTypes } from '../types/thread-types-enum.js'; +import type { LoggedInUserInfo } from '../types/user-types.js'; +import type { GetENSNames } from '../utils/ens-helpers.js'; +import { + entityTextToRawString, + getEntityTextAsString, +} from '../utils/entity-text.js'; +import type { GetFCNames } from '../utils/farcaster-helpers.js'; +import { useSelector } from '../utils/redux-utils.js'; +import { trimText } from '../utils/text-utils.js'; + +type SharedCreatePendingSidebarInput = { + +sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, + +parentThreadInfo: ThreadInfo, + +loggedInUserInfo: LoggedInUserInfo, +}; + +type BaseCreatePendingSidebarInput = { + ...SharedCreatePendingSidebarInput, + +messageTitle: string, +}; + +type UserIDAndUsername = { + +id: string, + +username: string, + ... +}; + +function baseCreatePendingSidebar( + input: BaseCreatePendingSidebarInput, +): ThreadInfo { + const { + sourceMessageInfo, + parentThreadInfo, + loggedInUserInfo, + messageTitle, + } = input; + const { color, type: parentThreadType } = parentThreadInfo; + const threadName = trimText(messageTitle, 30); + + const initialMembers = new Map(); + + const { id: viewerID, username: viewerUsername } = loggedInUserInfo; + initialMembers.set(viewerID, { id: viewerID, username: viewerUsername }); + + if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) { + const { id: sourceAuthorID, username: sourceAuthorUsername } = + sourceMessageInfo.creator; + invariant( + sourceAuthorUsername, + 'sourceAuthorUsername should be set in createPendingSidebar', + ); + const initialMemberUserInfo = { + id: sourceAuthorID, + username: sourceAuthorUsername, + }; + initialMembers.set(sourceAuthorID, initialMemberUserInfo); + } + + const singleOtherUser = getSingleOtherUser(parentThreadInfo, viewerID); + if (parentThreadType === threadTypes.PERSONAL && singleOtherUser) { + const singleOtherUsername = parentThreadInfo.members.find( + member => member.id === singleOtherUser, + )?.username; + invariant( + singleOtherUsername, + 'singleOtherUsername should be set in createPendingSidebar', + ); + const singleOtherUserInfo = { + id: singleOtherUser, + username: singleOtherUsername, + }; + initialMembers.set(singleOtherUser, singleOtherUserInfo); + } + + if (sourceMessageInfo.type === messageTypes.TEXT) { + const mentionedMembersOfParent = extractMentionedMembers( + sourceMessageInfo.text, + parentThreadInfo, + ); + for (const [memberID, member] of mentionedMembersOfParent) { + initialMembers.set(memberID, member); + } + } + + return createPendingThread({ + viewerID, + threadType: threadTypes.SIDEBAR, + members: [...initialMembers.values()], + parentThreadInfo, + threadColor: color, + name: threadName, + sourceMessageID: sourceMessageInfo.id, + }); +} + +// The message title here may have ETH addresses that aren't resolved to ENS +// names. This function should only be used in cases where we're sure that we +// don't care about the thread title. We should prefer createPendingSidebar +// wherever possible +type CreateUnresolvedPendingSidebarInput = { + ...SharedCreatePendingSidebarInput, + +markdownRules: ParserRules, +}; + +function createUnresolvedPendingSidebar( + input: CreateUnresolvedPendingSidebarInput, +): ThreadInfo { + const { + sourceMessageInfo, + parentThreadInfo, + loggedInUserInfo, + markdownRules, + } = input; + + const messageTitleEntityText = getMessageTitle( + sourceMessageInfo, + parentThreadInfo, + parentThreadInfo, + markdownRules, + ); + const messageTitle = entityTextToRawString(messageTitleEntityText, { + ignoreViewer: true, + }); + + return baseCreatePendingSidebar({ + sourceMessageInfo, + parentThreadInfo, + messageTitle, + loggedInUserInfo, + }); +} + +type CreatePendingSidebarInput = { + ...SharedCreatePendingSidebarInput, + +markdownRules: ParserRules, + +getENSNames: ?GetENSNames, + +getFCNames: ?GetFCNames, +}; + +async function createPendingSidebar( + input: CreatePendingSidebarInput, +): Promise { + const { + sourceMessageInfo, + parentThreadInfo, + loggedInUserInfo, + markdownRules, + getENSNames, + getFCNames, + } = input; + + const messageTitleEntityText = getMessageTitle( + sourceMessageInfo, + parentThreadInfo, + parentThreadInfo, + markdownRules, + ); + const messageTitle = await getEntityTextAsString( + messageTitleEntityText, + { getENSNames, getFCNames }, + { ignoreViewer: true }, + ); + invariant( + messageTitle !== null && messageTitle !== undefined, + 'getEntityTextAsString only returns falsey when passed falsey', + ); + + return baseCreatePendingSidebar({ + sourceMessageInfo, + parentThreadInfo, + messageTitle, + loggedInUserInfo, + }); +} + +function useCanCreateSidebarFromMessage( + threadInfo: ThreadInfo, + messageInfo: ComposableMessageInfo | RobotextMessageInfo, +): boolean { + const messageCreatorUserInfo = useSelector( + state => state.userStore.userInfos[messageInfo.creator.id], + ); + const hasCreateSidebarsPermission = useThreadHasPermission( + threadInfo, + threadPermissions.CREATE_SIDEBARS, + ); + if (!hasCreateSidebarsPermission) { + return false; + } + + if ( + !messageInfo.id || + threadInfo.sourceMessageID === messageInfo.id || + isInvalidSidebarSource(messageInfo) + ) { + return false; + } + + const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; + return ( + !messageCreatorRelationship || + !relationshipBlockedInEitherDirection(messageCreatorRelationship) + ); +} + +function useSidebarExistsOrCanBeCreated( + threadInfo: ThreadInfo, + messageItem: ChatMessageInfoItem, +): boolean { + const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( + threadInfo, + messageItem.messageInfo, + ); + return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; +} + +export { + createUnresolvedPendingSidebar, + createPendingSidebar, + useCanCreateSidebarFromMessage, + useSidebarExistsOrCanBeCreated, +}; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 733785951..9f6942be8 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1823 +1,1605 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omit from 'lodash/fp/omit.js'; import _omitBy from 'lodash/fp/omitBy.js'; import * as React from 'react'; import { getUserAvatarForThread } from './avatar-utils.js'; import { generatePendingThreadColor } from './color-utils.js'; -import { type ParserRules } from './markdown.js'; import { extractUserMentionsFromText } from './mention-utils.js'; -import { getMessageTitle, isInvalidSidebarSource } from './message-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import ashoat from '../facts/ashoat.js'; import genesis from '../facts/genesis.js'; import { useLoggedInUserInfo } from '../hooks/account-hooks.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { hasPermission, permissionsToBitmaskHex, threadPermissionsFromBitmaskHex, } from '../permissions/minimally-encoded-thread-permissions.js'; import { specialRoles } from '../permissions/special-roles.js'; import type { SpecialRole } from '../permissions/special-roles.js'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions.js'; -import type { - ChatThreadItem, - ChatMessageInfoItem, -} from '../selectors/chat-selectors.js'; +import type { ChatThreadItem } from '../selectors/chat-selectors.js'; import { threadInfoSelector, pendingToRealizedThreadIDsSelector, threadInfosSelectorForThreadType, onScreenThreadInfos, } from '../selectors/thread-selectors.js'; import { getRelativeMemberInfos, usersWithPersonalThreadSelector, } from '../selectors/user-selectors.js'; -import { messageTypes } from '../types/message-types-enum.js'; -import { - type RobotextMessageInfo, - type ComposableMessageInfo, -} from '../types/message-types.js'; import type { RelativeMemberInfo, RawThreadInfo, MemberInfo, ThreadCurrentUserInfo, RoleInfo, ThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { decodeMinimallyEncodedRoleInfo, minimallyEncodeMemberInfo, minimallyEncodeRawThreadInfo, minimallyEncodeRoleInfo, minimallyEncodeThreadCurrentUserInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissionPropagationPrefixes, threadPermissions, configurableCommunityPermissions, type ThreadPermission, type ThreadPermissionsInfo, type ThreadRolePermissionsBlob, type UserSurfacedPermission, threadPermissionFilterPrefixes, } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes, threadTypeIsCommunityRoot, assertThreadType, } from '../types/thread-types-enum.js'; import type { LegacyRawThreadInfo, ClientLegacyRoleInfo, ServerThreadInfo, ServerMemberInfo, UserProfileThreadInfo, MixedRawThreadInfos, LegacyMemberInfo, } from '../types/thread-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { type ClientUpdateInfo } from '../types/update-types.js'; import type { GlobalAccountUserInfo, UserInfos, AccountUserInfo, LoggedInUserInfo, UserInfo, } from '../types/user-types.js'; -import type { GetENSNames } from '../utils/ens-helpers.js'; import { ET, - entityTextToRawString, - getEntityTextAsString, type ThreadEntity, type UserEntity, } from '../utils/entity-text.js'; -import type { GetFCNames } from '../utils/farcaster-helpers.js'; import { entries, values } from '../utils/objects.js'; import { useSelector } from '../utils/redux-utils.js'; import { firstLine } from '../utils/string-utils.js'; -import { trimText } from '../utils/text-utils.js'; import { pendingThreadIDRegex } from '../utils/validation-utils.js'; function threadHasPermission( threadInfo: ?(ThreadInfo | LegacyRawThreadInfo | 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.minimallyEncoded) { return hasPermission(threadInfo.currentUser.permissions, permission); } return permissionLookup(threadInfo.currentUser.permissions, permission); } function useThreadsWithPermission( threadInfos: $ReadOnlyArray, permission: ThreadPermission, ): $ReadOnlyArray { const loggedInUserInfo = useLoggedInUserInfo(); const userInfos = useSelector(state => state.userStore.userInfos); return React.useMemo(() => { return threadInfos.filter((threadInfo: ThreadInfo) => { const permissions = threadFrozenDueToBlock( threadInfo, loggedInUserInfo?.id, userInfos, ) ? filterOutDisabledPermissions(threadInfo.currentUser.permissions) : threadInfo.currentUser.permissions; return hasPermission(permissions, permission); }); }, [loggedInUserInfo?.id, permission, threadInfos, userInfos]); } function useThreadHasPermission( threadInfo: ?ThreadInfo, permission: ThreadPermission, ): boolean { const threads = useThreadsWithPermission( threadInfo ? [threadInfo] : [], permission, ); return threads.length === 1; } function viewerIsMember( threadInfo: ?(ThreadInfo | LegacyRawThreadInfo | RawThreadInfo), ): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList( threadInfo: ?(LegacyRawThreadInfo | RawThreadInfo | ThreadInfo), ): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function useIsThreadInChatList(threadInfo: ?ThreadInfo): boolean { const threadIsVisible = useThreadHasPermission( threadInfo, threadPermissions.VISIBLE, ); return viewerIsMember(threadInfo) && threadIsVisible; } function useThreadsInChatList( threadInfos: $ReadOnlyArray, ): $ReadOnlyArray { const visibleThreads = useThreadsWithPermission( threadInfos, threadPermissions.VISIBLE, ); return React.useMemo( () => visibleThreads.filter(viewerIsMember), [visibleThreads], ); } function threadIsTopLevel(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return threadInChatList(threadInfo) && threadIsChannel(threadInfo); } function threadIsChannel(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return !!(threadInfo && threadInfo.type !== threadTypes.SIDEBAR); } function threadIsSidebar(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return threadInfo?.type === threadTypes.SIDEBAR; } function threadInBackgroundChatList( threadInfo: ?(RawThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?(RawThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?(LegacyRawThreadInfo | RawThreadInfo | ThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?(RawThreadInfo | ThreadInfo), userID: string, ): boolean { if (!threadInfo) { return false; } if (threadInfo.id === genesis().id) { return true; } return threadInfo.members.some(member => member.id === userID && member.role); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter(memberInfo => memberInfo.role) .map(memberInfo => memberInfo.id); } function threadOtherMembers< T: LegacyMemberInfo | MemberInfo | RelativeMemberInfo, >(memberInfos: $ReadOnlyArray, viewerID: ?string): $ReadOnlyArray { return memberInfos.filter( memberInfo => memberInfo.role && memberInfo.id !== viewerID, ); } function threadMembersWithoutAddedAdmin< T: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, >(threadInfo: T): $PropertyType { if (threadInfo.community !== genesis().id) { return threadInfo.members; } const adminID = extractKeyserverIDFromID(threadInfo.id); return threadInfo.members.filter( member => member.id !== adminID || member.role, ); } function threadIsGroupChat(threadInfo: ThreadInfo): boolean { return threadInfo.members.length > 2; } function threadOrParentThreadIsGroupChat( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ) { return threadMembersWithoutAddedAdmin(threadInfo).length > 2; } function threadIsPending(threadID: ?string): boolean { return !!threadID?.startsWith('pending'); } function threadIsPendingSidebar(threadID: ?string): boolean { return !!threadID?.startsWith('pending/sidebar/'); } function getSingleOtherUser( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, viewerID: ?string, ): ?string { if (!viewerID) { return undefined; } const otherMembers = threadOtherMembers(threadInfo.members, viewerID); if (otherMembers.length !== 1) { return undefined; } return otherMembers[0].id; } function getPendingThreadID( threadType: ThreadType, memberIDs: $ReadOnlyArray, sourceMessageID: ?string, ): string { const pendingThreadKey = sourceMessageID ? `sidebar/${sourceMessageID}` : [...memberIDs].sort().join('+'); const pendingThreadTypeString = sourceMessageID ? '' : `type${threadType}/`; return `pending/${pendingThreadTypeString}${pendingThreadKey}`; } type PendingThreadIDContents = { +threadType: ThreadType, +memberIDs: $ReadOnlyArray, +sourceMessageID: ?string, }; function parsePendingThreadID( pendingThreadID: string, ): ?PendingThreadIDContents { const pendingRegex = new RegExp(`^${pendingThreadIDRegex}$`); const pendingThreadIDMatches = pendingRegex.exec(pendingThreadID); if (!pendingThreadIDMatches) { return null; } const [threadTypeString, threadKey] = pendingThreadIDMatches[1].split('/'); const threadType = threadTypeString === 'sidebar' ? threadTypes.SIDEBAR : assertThreadType(Number(threadTypeString.replace('type', ''))); const memberIDs = threadTypeString === 'sidebar' ? [] : threadKey.split('+'); const sourceMessageID = threadTypeString === 'sidebar' ? threadKey : null; return { threadType, memberIDs, sourceMessageID, }; } type UserIDAndUsername = { +id: string, +username: string, ... }; type CreatePendingThreadArgs = { +viewerID: string, +threadType: ThreadType, +members: $ReadOnlyArray, +parentThreadInfo?: ?ThreadInfo, +threadColor?: ?string, +name?: ?string, +sourceMessageID?: string, }; function createPendingThread({ viewerID, threadType, members, parentThreadInfo, threadColor, name, sourceMessageID, }: CreatePendingThreadArgs): ThreadInfo { const now = Date.now(); if (!members.some(member => member.id === viewerID)) { throw new Error( 'createPendingThread should be called with the viewer as a member', ); } const memberIDs = members.map(member => member.id); const threadID = getPendingThreadID(threadType, memberIDs, sourceMessageID); const permissions: ThreadRolePermissionsBlob = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role: RoleInfo = { ...minimallyEncodeRoleInfo({ id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }), specialRole: specialRoles.DEFAULT_ROLE, }; const rawThreadInfo: RawThreadInfo = { minimallyEncoded: true, id: threadID, type: threadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID(parentThreadInfo, threadType), community: getCommunity(parentThreadInfo), members: members.map(member => minimallyEncodeMemberInfo({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, }), ), roles: { [role.id]: role, }, currentUser: minimallyEncodeThreadCurrentUserInfo({ role: role.id, permissions: membershipPermissions, subscription: { pushNotifs: false, home: false, }, unread: false, }), repliesCount: 0, sourceMessageID, pinnedCount: 0, }; const userInfos: { [string]: UserInfo } = {}; for (const member of members) { const { id, username } = member; userInfos[id] = { id, username }; } return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } type PendingPersonalThread = { +threadInfo: ThreadInfo, +pendingPersonalThreadUserInfo: UserInfo, }; function createPendingPersonalThread( loggedInUserInfo: LoggedInUserInfo, userID: string, username: string, ): PendingPersonalThread { const pendingPersonalThreadUserInfo = { id: userID, username: username, }; const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PERSONAL, members: [loggedInUserInfo, pendingPersonalThreadUserInfo], }); return { threadInfo, pendingPersonalThreadUserInfo }; } function createPendingThreadItem( loggedInUserInfo: LoggedInUserInfo, user: UserIDAndUsername, ): ChatThreadItem { const { threadInfo, pendingPersonalThreadUserInfo } = createPendingPersonalThread(loggedInUserInfo, user.id, user.username); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo, }; } // Returns map from lowercase username to AccountUserInfo function memberLowercaseUsernameMap( members: $ReadOnlyArray, ): Map { const memberMap = new Map(); for (const member of members) { const { id, role, username } = member; if (!role || !username) { continue; } memberMap.set(username.toLowerCase(), { id, username }); } return memberMap; } // Returns map from user ID to AccountUserInfo function extractMentionedMembers( text: string, threadInfo: ThreadInfo, ): Map { const memberMap = memberLowercaseUsernameMap(threadInfo.members); const mentions = extractUserMentionsFromText(text); const mentionedMembers = new Map(); for (const mention of mentions) { const userInfo = memberMap.get(mention.toLowerCase()); if (userInfo) { mentionedMembers.set(userInfo.id, userInfo); } } return mentionedMembers; } // When a member of the parent is mentioned in a sidebar, // they will be automatically added to that sidebar function extractNewMentionedParentMembers( messageText: string, threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, ): AccountUserInfo[] { const mentionedMembersOfParent = extractMentionedMembers( messageText, parentThreadInfo, ); for (const member of threadInfo.members) { if (member.role) { mentionedMembersOfParent.delete(member.id); } } return [...mentionedMembersOfParent.values()]; } -type SharedCreatePendingSidebarInput = { - +sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, - +parentThreadInfo: ThreadInfo, - +loggedInUserInfo: LoggedInUserInfo, -}; - -type BaseCreatePendingSidebarInput = { - ...SharedCreatePendingSidebarInput, - +messageTitle: string, -}; - -function baseCreatePendingSidebar( - input: BaseCreatePendingSidebarInput, -): ThreadInfo { - const { - sourceMessageInfo, - parentThreadInfo, - loggedInUserInfo, - messageTitle, - } = input; - const { color, type: parentThreadType } = parentThreadInfo; - const threadName = trimText(messageTitle, 30); - - const initialMembers = new Map(); - - const { id: viewerID, username: viewerUsername } = loggedInUserInfo; - initialMembers.set(viewerID, { id: viewerID, username: viewerUsername }); - - if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) { - const { id: sourceAuthorID, username: sourceAuthorUsername } = - sourceMessageInfo.creator; - invariant( - sourceAuthorUsername, - 'sourceAuthorUsername should be set in createPendingSidebar', - ); - const initialMemberUserInfo = { - id: sourceAuthorID, - username: sourceAuthorUsername, - }; - initialMembers.set(sourceAuthorID, initialMemberUserInfo); - } - - const singleOtherUser = getSingleOtherUser(parentThreadInfo, viewerID); - if (parentThreadType === threadTypes.PERSONAL && singleOtherUser) { - const singleOtherUsername = parentThreadInfo.members.find( - member => member.id === singleOtherUser, - )?.username; - invariant( - singleOtherUsername, - 'singleOtherUsername should be set in createPendingSidebar', - ); - const singleOtherUserInfo = { - id: singleOtherUser, - username: singleOtherUsername, - }; - initialMembers.set(singleOtherUser, singleOtherUserInfo); - } - - if (sourceMessageInfo.type === messageTypes.TEXT) { - const mentionedMembersOfParent = extractMentionedMembers( - sourceMessageInfo.text, - parentThreadInfo, - ); - for (const [memberID, member] of mentionedMembersOfParent) { - initialMembers.set(memberID, member); - } - } - - return createPendingThread({ - viewerID, - threadType: threadTypes.SIDEBAR, - members: [...initialMembers.values()], - parentThreadInfo, - threadColor: color, - name: threadName, - sourceMessageID: sourceMessageInfo.id, - }); -} - -// The message title here may have ETH addresses that aren't resolved to ENS -// names. This function should only be used in cases where we're sure that we -// don't care about the thread title. We should prefer createPendingSidebar -// wherever possible -type CreateUnresolvedPendingSidebarInput = { - ...SharedCreatePendingSidebarInput, - +markdownRules: ParserRules, -}; - -function createUnresolvedPendingSidebar( - input: CreateUnresolvedPendingSidebarInput, -): ThreadInfo { - const { - sourceMessageInfo, - parentThreadInfo, - loggedInUserInfo, - markdownRules, - } = input; - - const messageTitleEntityText = getMessageTitle( - sourceMessageInfo, - parentThreadInfo, - parentThreadInfo, - markdownRules, - ); - const messageTitle = entityTextToRawString(messageTitleEntityText, { - ignoreViewer: true, - }); - - return baseCreatePendingSidebar({ - sourceMessageInfo, - parentThreadInfo, - messageTitle, - loggedInUserInfo, - }); -} - -type CreatePendingSidebarInput = { - ...SharedCreatePendingSidebarInput, - +markdownRules: ParserRules, - +getENSNames: ?GetENSNames, - +getFCNames: ?GetFCNames, -}; - -async function createPendingSidebar( - input: CreatePendingSidebarInput, -): Promise { - const { - sourceMessageInfo, - parentThreadInfo, - loggedInUserInfo, - markdownRules, - getENSNames, - getFCNames, - } = input; - - const messageTitleEntityText = getMessageTitle( - sourceMessageInfo, - parentThreadInfo, - parentThreadInfo, - markdownRules, - ); - const messageTitle = await getEntityTextAsString( - messageTitleEntityText, - { getENSNames, getFCNames }, - { ignoreViewer: true }, - ); - invariant( - messageTitle !== null && messageTitle !== undefined, - 'getEntityTextAsString only returns falsey when passed falsey', - ); - - return baseCreatePendingSidebar({ - sourceMessageInfo, - parentThreadInfo, - messageTitle, - loggedInUserInfo, - }); -} - function 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 RawThreadInfoOptions = { +filterThreadEditAvatarPermission?: boolean, +excludePinInfo?: boolean, +filterManageInviteLinksPermission?: boolean, +filterVoicedInAnnouncementChannelsPermission?: boolean, +minimallyEncodePermissions?: boolean, +includeSpecialRoleFieldInRoles?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?LegacyRawThreadInfo | ?RawThreadInfo { const filterThreadEditAvatarPermission = options?.filterThreadEditAvatarPermission; const excludePinInfo = options?.excludePinInfo; const filterManageInviteLinksPermission = options?.filterManageInviteLinksPermission; const filterVoicedInAnnouncementChannelsPermission = options?.filterVoicedInAnnouncementChannelsPermission; const shouldMinimallyEncodePermissions = options?.minimallyEncodePermissions; const shouldIncludeSpecialRoleFieldInRoles = options?.includeSpecialRoleFieldInRoles; const filterThreadPermissions = _omitBy( (v, k) => (filterThreadEditAvatarPermission && [ threadPermissions.EDIT_THREAD_AVATAR, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR, ].includes(k)) || (excludePinInfo && [ threadPermissions.MANAGE_PINS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.MANAGE_PINS, ].includes(k)) || (filterManageInviteLinksPermission && [threadPermissions.MANAGE_INVITE_LINKS].includes(k)) || (filterVoicedInAnnouncementChannelsPermission && [ threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.TOP_LEVEL + threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, ].includes(k)), ); const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( serverThreadInfo.id === genesis().id && serverMember.id !== viewerID && serverMember.id !== ashoat.id ) { continue; } const memberPermissions = filterThreadPermissions(serverMember.permissions); members.push({ id: serverMember.id, role: serverMember.role, permissions: memberPermissions, isSender: serverMember.isSender, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: memberPermissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = filterThreadPermissions( getAllThreadPermissions(null, serverThreadInfo.id), ); currentUser = { role: null, permissions: currentUserPermissions, subscription: { home: false, pushNotifs: false, }, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } const rolesWithFilteredThreadPermissions = _mapValues(role => ({ ...role, permissions: filterThreadPermissions(role.permissions), }))(serverThreadInfo.roles); const rolesWithoutSpecialRoleField = _mapValues(role => { const { specialRole, ...roleSansSpecialRole } = role; return roleSansSpecialRole; })(rolesWithFilteredThreadPermissions); let rawThreadInfo: any = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: rolesWithoutSpecialRoleField, currentUser, repliesCount: serverThreadInfo.repliesCount, containingThreadID: serverThreadInfo.containingThreadID, community: serverThreadInfo.community, }; const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } if (serverThreadInfo.avatar) { rawThreadInfo = { ...rawThreadInfo, avatar: serverThreadInfo.avatar }; } if (!excludePinInfo) { rawThreadInfo = { ...rawThreadInfo, pinnedCount: serverThreadInfo.pinnedCount, }; } if (!shouldMinimallyEncodePermissions) { return rawThreadInfo; } const minimallyEncoded = minimallyEncodeRawThreadInfo(rawThreadInfo); if (shouldIncludeSpecialRoleFieldInRoles) { return minimallyEncoded; } const minimallyEncodedRolesWithoutSpecialRoleField = Object.fromEntries( entries(minimallyEncoded.roles).map(([key, role]) => [ key, { ..._omit('specialRole')(role), isDefault: roleIsDefaultRole(role), }, ]), ); return { ...minimallyEncoded, roles: minimallyEncodedRolesWithoutSpecialRoleField, }; } function threadUIName(threadInfo: ThreadInfo): string | ThreadEntity { if (threadInfo.name) { return firstLine(threadInfo.name); } const threadMembers: $ReadOnlyArray = threadInfo.members.filter(memberInfo => memberInfo.role); const memberEntities: $ReadOnlyArray = threadMembers.map(member => ET.user({ userInfo: member }), ); return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: memberEntities, ifJustViewer: threadInfo.type === threadTypes.PRIVATE ? 'viewer_username' : 'just_you_string', }; } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { let threadInfo: ThreadInfo = { minimallyEncoded: true, id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: '', description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, containingThreadID: rawThreadInfo.containingThreadID, community: rawThreadInfo.community, members: getRelativeMemberInfos(rawThreadInfo, viewerID, userInfos), roles: rawThreadInfo.roles, currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos), repliesCount: rawThreadInfo.repliesCount, }; threadInfo = { ...threadInfo, uiName: threadUIName(threadInfo), }; const { sourceMessageID, avatar, pinnedCount } = rawThreadInfo; if (sourceMessageID) { threadInfo = { ...threadInfo, sourceMessageID }; } if (avatar) { threadInfo = { ...threadInfo, avatar }; } else if ( rawThreadInfo.type === threadTypes.PERSONAL || rawThreadInfo.type === threadTypes.PRIVATE ) { threadInfo = { ...threadInfo, avatar: getUserAvatarForThread(rawThreadInfo, viewerID, userInfos), }; } if (pinnedCount) { threadInfo = { ...threadInfo, pinnedCount }; } return threadInfo; } function filterOutDisabledPermissions(permissionsBitmask: string): string { const decodedPermissions: ThreadPermissionsInfo = threadPermissionsFromBitmaskHex(permissionsBitmask); const updatedPermissions = { ...decodedPermissions, ...disabledPermissions }; const encodedUpdatedPermissions: string = permissionsToBitmaskHex(updatedPermissions); return encodedUpdatedPermissions; } function getCurrentUser( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } return { ...threadInfo.currentUser, permissions: filterOutDisabledPermissions( threadInfo.currentUser.permissions, ), }; } function threadIsWithBlockedUserOnly( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( 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: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( threadInfo: RawThreadInfo | ThreadInfo, communityThreadInfo: ?(RawThreadInfo | ThreadInfo), viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true); } const threadTypeDescriptions: { [ThreadType]: string } = { [threadTypes.COMMUNITY_OPEN_SUBTHREAD]: 'Anybody in the parent channel can see an open subchannel.', [threadTypes.COMMUNITY_SECRET_SUBTHREAD]: 'Only visible to its members and admins of ancestor channels.', }; // Since we don't have access to all of the ancestor ThreadInfos, we approximate // "parent admin" as anybody with CHANGE_ROLE permissions. function memberHasAdminPowers( memberInfo: | RelativeMemberInfo | LegacyMemberInfo | MemberInfo | ServerMemberInfo, ): boolean { if (memberInfo.minimallyEncoded) { return hasPermission(memberInfo.permissions, threadPermissions.CHANGE_ROLE); } return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsDefaultRole( roleInfo: ?ClientLegacyRoleInfo | ?RoleInfo, ): boolean { if (roleInfo?.specialRole === specialRoles.DEFAULT_ROLE) { return true; } return !!(roleInfo && roleInfo.isDefault); } function roleIsAdminRole(roleInfo: ?ClientLegacyRoleInfo | ?RoleInfo): boolean { if (roleInfo?.specialRole === specialRoles.ADMIN_ROLE) { return true; } return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } function threadHasAdminRole( threadInfo: ?( | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo | ServerThreadInfo ), ): boolean { if (!threadInfo) { return false; } let hasSpecialRoleFieldBeenEncountered = false; for (const role of Object.values(threadInfo.roles)) { if (role.specialRole === specialRoles.ADMIN_ROLE) { return true; } if (role.specialRole !== undefined) { hasSpecialRoleFieldBeenEncountered = true; } } if (hasSpecialRoleFieldBeenEncountered) { return false; } return !!_find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ) { return ( threadMembersWithoutAddedAdmin(threadInfo).filter(member => memberHasAdminPowers(member), ).length > 0 ); } function identifyInvalidatedThreads( updateInfos: $ReadOnlyArray, ): Set { const invalidated = new Set(); for (const updateInfo of updateInfos) { if (updateInfo.type === updateTypes.DELETE_THREAD) { invalidated.add(updateInfo.threadID); } } return invalidated; } const permissionsDisabledByBlockArray = [ 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.`; function threadNoun(threadType: ThreadType, parentThreadID: ?string): string { if (threadType === threadTypes.SIDEBAR) { return 'thread'; } else if ( threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD && parentThreadID === genesis().id ) { return 'chat'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.GENESIS ) { return 'channel'; } else { return 'chat'; } } function threadLabel(threadType: ThreadType): string { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ) { return 'Open'; } else if (threadType === threadTypes.PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.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'; } } type ExistingThreadInfoFinderParams = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, }; type ExistingThreadInfoFinder = ( params: ExistingThreadInfoFinderParams, ) => ?ThreadInfo; function useExistingThreadInfoFinder( baseThreadInfo: ?ThreadInfo, ): ExistingThreadInfoFinder { const threadInfos = useSelector(threadInfoSelector); const loggedInUserInfo = useLoggedInUserInfo(); const userInfos = useSelector(state => state.userStore.userInfos); const pendingToRealizedThreadIDs = useSelector(state => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); return React.useCallback( (params: ExistingThreadInfoFinderParams): ?ThreadInfo => { if (!baseThreadInfo) { return null; } const realizedThreadInfo = threadInfos[baseThreadInfo.id]; if (realizedThreadInfo) { return realizedThreadInfo; } if (!loggedInUserInfo || !threadIsPending(baseThreadInfo.id)) { return baseThreadInfo; } const viewerID = loggedInUserInfo?.id; invariant( threadTypeCanBePending(baseThreadInfo.type), `ThreadInfo has pending ID ${baseThreadInfo.id}, but type that ` + `should not be pending ${baseThreadInfo.type}`, ); const { searching, userInfoInputArray } = params; const { sourceMessageID } = baseThreadInfo; const pendingThreadID = searching ? getPendingThreadID( pendingThreadType(userInfoInputArray.length), [...userInfoInputArray.map(user => user.id), viewerID], sourceMessageID, ) : getPendingThreadID( baseThreadInfo.type, baseThreadInfo.members.map(member => member.id), sourceMessageID, ); const realizedThreadID = pendingToRealizedThreadIDs.get(pendingThreadID); if (realizedThreadID && threadInfos[realizedThreadID]) { return threadInfos[realizedThreadID]; } const updatedThread = searching ? createPendingThread({ viewerID, threadType: pendingThreadType(userInfoInputArray.length), members: [loggedInUserInfo, ...userInfoInputArray], }) : baseThreadInfo; return { ...updatedThread, currentUser: getCurrentUser(updatedThread, viewerID, userInfos), }; }, [ baseThreadInfo, threadInfos, loggedInUserInfo, pendingToRealizedThreadIDs, 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: ThreadInfo, memberID: string, permission: ThreadPermission, ): boolean { for (const member of threadInfo.members) { if (member.id !== memberID) { continue; } return hasPermission(member.permissions, permission); } return false; } -function useCanCreateSidebarFromMessage( - threadInfo: ThreadInfo, - messageInfo: ComposableMessageInfo | RobotextMessageInfo, -): boolean { - const messageCreatorUserInfo = useSelector( - state => state.userStore.userInfos[messageInfo.creator.id], - ); - const hasCreateSidebarsPermission = useThreadHasPermission( - threadInfo, - threadPermissions.CREATE_SIDEBARS, - ); - if (!hasCreateSidebarsPermission) { - return false; - } - - if ( - !messageInfo.id || - threadInfo.sourceMessageID === messageInfo.id || - isInvalidSidebarSource(messageInfo) - ) { - return false; - } - - const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; - return ( - !messageCreatorRelationship || - !relationshipBlockedInEitherDirection(messageCreatorRelationship) - ); -} - -function useSidebarExistsOrCanBeCreated( - threadInfo: ThreadInfo, - messageItem: ChatMessageInfoItem, -): boolean { - const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( - threadInfo, - messageItem.messageInfo, - ); - return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; -} - function checkIfDefaultMembersAreVoiced(threadInfo: ThreadInfo): boolean { const defaultRoleID = Object.keys(threadInfo.roles).find(roleID => roleIsDefaultRole(threadInfo.roles[roleID]), ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = threadInfo.roles[defaultRoleID]; const defaultRolePermissions = decodeMinimallyEncodedRoleInfo(defaultRole).permissions; return !!defaultRolePermissions[threadPermissions.VOICED]; } const draftKeySuffix = '/message_composer'; function draftKeyFromThreadID(threadID: string): string { return `${threadID}${draftKeySuffix}`; } function getContainingThreadID( parentThreadInfo: | ?ServerThreadInfo | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, threadType: ThreadType, ): ?string { if (!parentThreadInfo) { return null; } if (threadType === threadTypes.SIDEBAR) { return parentThreadInfo.id; } if (!parentThreadInfo.containingThreadID) { return parentThreadInfo.id; } return parentThreadInfo.containingThreadID; } function getCommunity( parentThreadInfo: | ?ServerThreadInfo | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ): ?string { if (!parentThreadInfo) { return null; } const { id, community, type } = parentThreadInfo; if (community !== null && community !== undefined) { return community; } if (threadTypeIsCommunityRoot(type)) { return id; } return null; } function getThreadListSearchResults( chatListData: $ReadOnlyArray, searchText: string, threadFilter: ThreadInfo => boolean, threadSearchResults: $ReadOnlySet, usersSearchResults: $ReadOnlyArray, loggedInUserInfo: ?LoggedInUserInfo, ): $ReadOnlyArray { if (!searchText) { return chatListData.filter( item => threadIsTopLevel(item.threadInfo) && threadFilter(item.threadInfo), ); } const privateThreads = []; const personalThreads = []; const otherThreads = []; for (const item of chatListData) { if (!threadSearchResults.has(item.threadInfo.id)) { continue; } if (item.threadInfo.type === threadTypes.PRIVATE) { privateThreads.push({ ...item, sidebars: [] }); } else if (item.threadInfo.type === threadTypes.PERSONAL) { personalThreads.push({ ...item, sidebars: [] }); } else { otherThreads.push({ ...item, sidebars: [] }); } } const chatItems: ChatThreadItem[] = [ ...privateThreads, ...personalThreads, ...otherThreads, ]; if (loggedInUserInfo) { chatItems.push( ...usersSearchResults.map(user => createPendingThreadItem(loggedInUserInfo, user), ), ); } return chatItems; } function useAvailableThreadMemberActions( memberInfo: RelativeMemberInfo, threadInfo: ThreadInfo, canEdit: ?boolean = true, ): $ReadOnlyArray<'change_role' | 'remove_user'> { const canRemoveMembers = useThreadHasPermission( threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = useThreadHasPermission( threadInfo, threadPermissions.CHANGE_ROLE, ); return React.useMemo(() => { const { role } = memberInfo; if (!canEdit || !role) { return []; } const result = []; if ( canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo) ) { result.push('change_role'); } if ( canRemoveMembers && !memberInfo.isViewer && (canChangeRoles || roleIsDefaultRole(threadInfo.roles[role])) ) { result.push('remove_user'); } return result; }, [canChangeRoles, canEdit, canRemoveMembers, memberInfo, threadInfo]); } function patchThreadInfoToIncludeMentionedMembersOfParent( threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, messageText: string, viewerID: string, ): ThreadInfo { const members: UserIDAndUsername[] = threadInfo.members .map(({ id, username }) => username ? ({ id, username }: UserIDAndUsername) : null, ) .filter(Boolean); const mentionedNewMembers = extractNewMentionedParentMembers( messageText, threadInfo, parentThreadInfo, ); if (mentionedNewMembers.length === 0) { return threadInfo; } members.push(...mentionedNewMembers); return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members, parentThreadInfo, threadColor: threadInfo.color, name: threadInfo.name, sourceMessageID: threadInfo.sourceMessageID, }); } function threadInfoInsideCommunity( threadInfo: RawThreadInfo | ThreadInfo, communityID: string, ): boolean { return threadInfo.community === communityID || threadInfo.id === communityID; } type RoleAndMemberCount = { [roleName: string]: number, }; function useRoleMemberCountsForCommunity( threadInfo: ThreadInfo, ): RoleAndMemberCount { return React.useMemo(() => { const roleIDsToNames: { [string]: string } = {}; Object.keys(threadInfo.roles).forEach(roleID => { roleIDsToNames[roleID] = threadInfo.roles[roleID].name; }); const roleNamesToMemberCount: RoleAndMemberCount = {}; threadInfo.members.forEach(({ role: roleID }) => { invariant(roleID, 'Community member should have a role'); const roleName = roleIDsToNames[roleID]; roleNamesToMemberCount[roleName] = (roleNamesToMemberCount[roleName] ?? 0) + 1; }); // For all community roles with no members, add them to the list with 0 Object.keys(roleIDsToNames).forEach(roleName => { if (roleNamesToMemberCount[roleIDsToNames[roleName]] === undefined) { roleNamesToMemberCount[roleIDsToNames[roleName]] = 0; } }); return roleNamesToMemberCount; }, [threadInfo.members, threadInfo.roles]); } function useRoleNamesToSpecialRole(threadInfo: ThreadInfo): { +[roleName: string]: ?SpecialRole, } { return React.useMemo(() => { const roleNamesToSpecialRole: { [roleName: string]: ?SpecialRole } = {}; values(threadInfo.roles).forEach(role => { if (roleNamesToSpecialRole[role.name] !== undefined) { return; } if (roleIsDefaultRole(role)) { roleNamesToSpecialRole[role.name] = specialRoles.DEFAULT_ROLE; } else if (roleIsAdminRole(role)) { roleNamesToSpecialRole[role.name] = specialRoles.ADMIN_ROLE; } else { roleNamesToSpecialRole[role.name] = null; } }); return roleNamesToSpecialRole; }, [threadInfo.roles]); } type RoleUserSurfacedPermissions = { +[roleName: string]: $ReadOnlySet, }; // Iterates through the existing roles in the community and 'reverse maps' // the set of permission literals for each role to user-facing permission enums // to help pre-populate the permission checkboxes when editing roles. function useRoleUserSurfacedPermissions( threadInfo: ThreadInfo, ): RoleUserSurfacedPermissions { return React.useMemo(() => { const roleNamesToPermissions: { [string]: Set } = {}; Object.keys(threadInfo.roles).forEach(roleID => { const roleName = threadInfo.roles[roleID].name; const rolePermissions = Object.keys( decodeMinimallyEncodedRoleInfo(threadInfo.roles[roleID]).permissions, ); const setOfUserSurfacedPermissions = new Set(); rolePermissions.forEach(rolePermission => { const userSurfacedPermission = Object.keys( configurableCommunityPermissions, ).find(key => configurableCommunityPermissions[key].has(rolePermission), ); if (userSurfacedPermission) { setOfUserSurfacedPermissions.add(userSurfacedPermission); } }); roleNamesToPermissions[roleName] = setOfUserSurfacedPermissions; }); return roleNamesToPermissions; }, [threadInfo.roles]); } function communityOrThreadNoun(threadInfo: RawThreadInfo | ThreadInfo): string { return threadTypeIsCommunityRoot(threadInfo.type) ? 'community' : threadNoun(threadInfo.type, threadInfo.parentThreadID); } function getThreadsToDeleteText( threadInfo: RawThreadInfo | ThreadInfo, ): string { return `${ threadTypeIsCommunityRoot(threadInfo.type) ? 'Subchannels and threads' : 'Threads' } within this ${communityOrThreadNoun(threadInfo)}`; } function useUserProfileThreadInfo(userInfo: ?UserInfo): ?UserProfileThreadInfo { const userID = userInfo?.id; const username = userInfo?.username; const loggedInUserInfo = useLoggedInUserInfo(); const isViewerProfile = loggedInUserInfo?.id === userID; const privateThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PRIVATE, ); const privateThreadInfos = useSelector(privateThreadInfosSelector); const personalThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PERSONAL, ); const personalThreadInfos = useSelector(personalThreadInfosSelector); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); return React.useMemo(() => { if (!loggedInUserInfo || !userID || !username) { return null; } if (isViewerProfile) { const privateThreadInfo: ?ThreadInfo = privateThreadInfos[0]; return privateThreadInfo ? { threadInfo: privateThreadInfo } : null; } if (usersWithPersonalThread.has(userID)) { const personalThreadInfo: ?ThreadInfo = personalThreadInfos.find( threadInfo => userID === getSingleOtherUser(threadInfo, loggedInUserInfo.id), ); return personalThreadInfo ? { threadInfo: personalThreadInfo } : null; } const pendingPersonalThreadInfo = createPendingPersonalThread( loggedInUserInfo, userID, username, ); return pendingPersonalThreadInfo; }, [ isViewerProfile, loggedInUserInfo, personalThreadInfos, privateThreadInfos, userID, username, usersWithPersonalThread, ]); } function assertAllThreadInfosAreLegacy(rawThreadInfos: MixedRawThreadInfos): { [id: string]: LegacyRawThreadInfo, } { return Object.fromEntries( Object.entries(rawThreadInfos).map(([id, rawThreadInfo]) => { invariant( !rawThreadInfo.minimallyEncoded, `rawThreadInfos shouldn't be minimallyEncoded`, ); return [id, rawThreadInfo]; }), ); } function useOnScreenEntryEditableThreadInfos(): $ReadOnlyArray { const visibleThreadInfos = useSelector(onScreenThreadInfos); const editableVisibleThreadInfos = useThreadsWithPermission( visibleThreadInfos, threadPermissions.EDIT_ENTRIES, ); return editableVisibleThreadInfos; } export { threadHasPermission, useThreadHasPermission, viewerIsMember, threadInChatList, useIsThreadInChatList, useThreadsInChatList, threadIsTopLevel, threadIsChannel, threadIsSidebar, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadOtherMembers, threadIsGroupChat, threadIsPending, threadIsPendingSidebar, getSingleOtherUser, getPendingThreadID, parsePendingThreadID, createPendingThread, - createUnresolvedPendingSidebar, extractNewMentionedParentMembers, - createPendingSidebar, pendingThreadType, filterOutDisabledPermissions, getCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, threadUIName, threadInfoFromRawThreadInfo, threadTypeDescriptions, memberHasAdminPowers, roleIsDefaultRole, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadNoun, threadLabel, useExistingThreadInfoFinder, getThreadTypeParentRequirement, threadMemberHasPermission, - useCanCreateSidebarFromMessage, - useSidebarExistsOrCanBeCreated, checkIfDefaultMembersAreVoiced, draftKeySuffix, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, useAvailableThreadMemberActions, threadMembersWithoutAddedAdmin, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, useRoleMemberCountsForCommunity, useRoleNamesToSpecialRole, useRoleUserSurfacedPermissions, getThreadsToDeleteText, useUserProfileThreadInfo, assertAllThreadInfosAreLegacy, useOnScreenEntryEditableThreadInfos, + extractMentionedMembers, }; diff --git a/native/chat/multimedia-message.react.js b/native/chat/multimedia-message.react.js index da0413964..39e550116 100644 --- a/native/chat/multimedia-message.react.js +++ b/native/chat/multimedia-message.react.js @@ -1,254 +1,254 @@ // @flow import type { LeafRoute, NavigationProp, ParamListBase, } from '@react-navigation/core'; import { useNavigation, useRoute } from '@react-navigation/native'; import * as React from 'react'; import { View } from 'react-native'; import { messageKey } from 'lib/shared/message-utils.js'; -import { useCanCreateSidebarFromMessage } from 'lib/shared/thread-utils.js'; +import { useCanCreateSidebarFromMessage } from 'lib/shared/sidebar-utils.js'; import type { MediaInfo } from 'lib/types/media-types.js'; import ComposedMessage from './composed-message.react.js'; import { InnerMultimediaMessage } from './inner-multimedia-message.react.js'; import { getMediaKey, multimediaMessageSendFailed, } from './multimedia-message-utils.js'; import { getMessageTooltipKey } from './utils.js'; import { ChatContext, type ChatContextType } from '../chat/chat-context.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import type { OverlayContextType } from '../navigation/overlay-context.js'; import { ImageModalRouteName, MultimediaMessageTooltipModalRouteName, VideoPlaybackModalRouteName, } from '../navigation/route-names.js'; import { fixedTooltipHeight } from '../tooltip/tooltip.react.js'; import type { ChatMultimediaMessageInfoItem } from '../types/chat-types.js'; import type { VerticalBounds, LayoutCoordinates, } from '../types/layout-types.js'; type BaseProps = { ...React.ElementConfig, +item: ChatMultimediaMessageInfoItem, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, +canTogglePins: boolean, +shouldDisplayPinIndicator: boolean, }; type Props = { ...BaseProps, +navigation: NavigationProp, +route: LeafRoute<>, +overlayContext: ?OverlayContextType, +chatContext: ?ChatContextType, +canCreateSidebarFromMessage: boolean, }; type State = { +clickable: boolean, }; class MultimediaMessage extends React.PureComponent { state: State = { clickable: true, }; view: ?React.ElementRef; setClickable = (clickable: boolean) => { this.setState({ clickable }); }; onPressMultimedia = ( mediaInfo: MediaInfo, initialCoordinates: LayoutCoordinates, ) => { const { navigation, item, route, verticalBounds } = this.props; navigation.navigate<'VideoPlaybackModal' | 'ImageModal'>({ name: mediaInfo.type === 'video' || mediaInfo.type === 'encrypted_video' ? VideoPlaybackModalRouteName : ImageModalRouteName, key: getMediaKey(item, mediaInfo), params: { presentedFrom: route.key, mediaInfo, item, initialCoordinates, verticalBounds, }, }); }; visibleEntryIDs(): $ReadOnlyArray { const result = []; if (this.props.canTogglePins) { this.props.item.isPinned ? result.push('unpin') : result.push('pin'); } if ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ) { result.push('sidebar'); } if (!this.props.item.messageInfo.creator.isViewer) { result.push('report'); } return result; } onLayout = () => {}; viewRef = (view: ?React.ElementRef) => { this.view = view; }; onLongPress = () => { const visibleEntryIDs = this.visibleEntryIDs(); if (visibleEntryIDs.length === 0) { return; } const { view, props: { verticalBounds }, } = this; if (!view || !verticalBounds) { return; } if (!this.state.clickable) { return; } this.setClickable(false); const { item } = this.props; if (!this.props.focused) { this.props.toggleFocus(messageKey(item.messageInfo)); } this.props.overlayContext?.setScrollBlockingModalStatus('open'); view.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const multimediaTop = pageY; const multimediaBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = fixedTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = fixedTooltipHeight + aboveMargin; let margin = belowMargin; if ( multimediaBottom + belowSpace > boundsBottom && multimediaTop - aboveSpace > boundsTop ) { margin = aboveMargin; } const currentInputBarHeight = this.props.chatContext?.chatInputBarHeights.get(item.threadInfo.id) ?? 0; this.props.navigation.navigate<'MultimediaMessageTooltipModal'>({ name: MultimediaMessageTooltipModalRouteName, params: { presentedFrom: this.props.route.key, item, initialCoordinates: coordinates, verticalBounds, tooltipLocation: 'fixed', margin, visibleEntryIDs, chatInputBarHeight: currentInputBarHeight, }, key: getMessageTooltipKey(item), }); }); }; canNavigateToSidebar(): boolean { return ( !!this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ); } render(): React.Node { const { item, focused, toggleFocus, verticalBounds, shouldDisplayPinIndicator, navigation, route, overlayContext, chatContext, canCreateSidebarFromMessage, canTogglePins, ...viewProps } = this.props; return ( ); } } const ConnectedMultimediaMessage: React.ComponentType = React.memo(function ConnectedMultimediaMessage(props: BaseProps) { const navigation = useNavigation(); const route = useRoute(); const overlayContext = React.useContext(OverlayContext); const chatContext = React.useContext(ChatContext); const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( props.item.threadInfo, props.item.messageInfo, ); return ( ); }); export default ConnectedMultimediaMessage; diff --git a/native/chat/robotext-message.react.js b/native/chat/robotext-message.react.js index 66122c161..2d845f803 100644 --- a/native/chat/robotext-message.react.js +++ b/native/chat/robotext-message.react.js @@ -1,240 +1,240 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { messageKey } from 'lib/shared/message-utils.js'; -import { useCanCreateSidebarFromMessage } from 'lib/shared/thread-utils.js'; +import { useCanCreateSidebarFromMessage } from 'lib/shared/sidebar-utils.js'; import { inlineEngagementCenterStyle } from './chat-constants.js'; import type { ChatNavigationProp } from './chat.react.js'; import { InlineEngagement } from './inline-engagement.react.js'; import { InnerRobotextMessage } from './inner-robotext-message.react.js'; import { Timestamp } from './timestamp.react.js'; import { getMessageTooltipKey, useContentAndHeaderOpacity } from './utils.js'; import { ChatContext } from '../chat/chat-context.js'; import { KeyboardContext } from '../keyboard/keyboard-state.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { OverlayContext } from '../navigation/overlay-context.js'; import { RobotextMessageTooltipModalRouteName } from '../navigation/route-names.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import { fixedTooltipHeight } from '../tooltip/tooltip.react.js'; import type { ChatRobotextMessageInfoItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; import { AnimatedView } from '../types/styles.js'; type Props = { ...React.ElementConfig, +item: ChatRobotextMessageInfoItemWithHeight, +navigation: | ChatNavigationProp<'MessageList'> | AppNavigationProp<'TogglePinModal'> | ChatNavigationProp<'PinnedMessagesScreen'> | ChatNavigationProp<'MessageSearch'>, +route: | NavigationRoute<'MessageList'> | NavigationRoute<'TogglePinModal'> | NavigationRoute<'PinnedMessagesScreen'> | NavigationRoute<'MessageSearch'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, }; function RobotextMessage(props: Props): React.Node { const { item, navigation, route, focused, toggleFocus, verticalBounds, ...viewProps } = props; let timestamp = null; if (focused || item.startsConversation) { timestamp = ( ); } const styles = useStyles(unboundStyles); let inlineEngagement = null; if (item.threadCreatedFromMessage || Object.keys(item.reactions).length > 0) { inlineEngagement = ( ); } const chatContext = React.useContext(ChatContext); const keyboardState = React.useContext(KeyboardContext); const key = messageKey(item.messageInfo); const onPress = React.useCallback(() => { const didDismiss = keyboardState && keyboardState.dismissKeyboardIfShowing(); if (!didDismiss) { toggleFocus(key); } }, [keyboardState, toggleFocus, key]); const overlayContext = React.useContext(OverlayContext); const viewRef = React.useRef>(); const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( item.threadInfo, item.messageInfo, ); const visibleEntryIDs = React.useMemo(() => { const result = []; if (item.threadCreatedFromMessage || canCreateSidebarFromMessage) { result.push('sidebar'); } return result; }, [item.threadCreatedFromMessage, canCreateSidebarFromMessage]); const openRobotextTooltipModal = React.useCallback( ( x: number, y: number, width: number, height: number, pageX: number, pageY: number, ) => { invariant( verticalBounds, 'verticalBounds should be present in openRobotextTooltipModal', ); const coordinates = { x: pageX, y: pageY, width, height }; const messageTop = pageY; const messageBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = fixedTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = fixedTooltipHeight + aboveMargin; let margin = 0; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { margin = aboveMargin; } const currentInputBarHeight = chatContext?.chatInputBarHeights.get(item.threadInfo.id) ?? 0; props.navigation.navigate<'RobotextMessageTooltipModal'>({ name: RobotextMessageTooltipModalRouteName, params: { presentedFrom: props.route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs, tooltipLocation: 'fixed', margin, item, chatInputBarHeight: currentInputBarHeight, }, key: getMessageTooltipKey(item), }); }, [ item, props.navigation, props.route.key, verticalBounds, visibleEntryIDs, chatContext, ], ); const onLongPress = React.useCallback(() => { if (keyboardState && keyboardState.dismissKeyboardIfShowing()) { return; } if (visibleEntryIDs.length === 0) { return; } if (!viewRef.current || !verticalBounds) { return; } if (!focused) { toggleFocus(messageKey(item.messageInfo)); } invariant(overlayContext, 'RobotextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); viewRef.current?.measure(openRobotextTooltipModal); }, [ focused, item, keyboardState, overlayContext, toggleFocus, verticalBounds, viewRef, visibleEntryIDs, openRobotextTooltipModal, ]); const onLayout = React.useCallback(() => {}, []); const contentAndHeaderOpacity = useContentAndHeaderOpacity(item); const viewStyle: { height?: number } = {}; if (!__DEV__) { // We don't force view height in dev mode because we // want to measure it in Message to see if it's correct viewStyle.height = item.contentHeight; } return ( {timestamp} {inlineEngagement} ); } const unboundStyles = { sidebar: { marginTop: inlineEngagementCenterStyle.topOffset, marginBottom: -inlineEngagementCenterStyle.topOffset, alignSelf: 'center', }, }; export { RobotextMessage }; diff --git a/native/chat/sidebar-navigation.js b/native/chat/sidebar-navigation.js index ea239e99b..95d2058e9 100644 --- a/native/chat/sidebar-navigation.js +++ b/native/chat/sidebar-navigation.js @@ -1,139 +1,139 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ENSCacheContext } from 'lib/components/ens-cache-provider.react.js'; import { NeynarClientContext } from 'lib/components/neynar-client-provider.react.js'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { createPendingSidebar, createUnresolvedPendingSidebar, -} from 'lib/shared/thread-utils.js'; +} from 'lib/shared/sidebar-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ChatMentionCandidates } from 'lib/types/thread-types.js'; import type { LoggedInUserInfo } from 'lib/types/user-types.js'; import type { GetENSNames } from 'lib/utils/ens-helpers.js'; import type { GetFCNames } from 'lib/utils/farcaster-helpers.js'; import { ChatContext } from './chat-context.js'; import { useNavigateToThread } from './message-list-types.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; type GetUnresolvedSidebarThreadInfoInput = { +sourceMessage: ChatMessageInfoItemWithHeight, +loggedInUserInfo: ?LoggedInUserInfo, +chatMentionCandidates: ChatMentionCandidates, }; function getUnresolvedSidebarThreadInfo( input: GetUnresolvedSidebarThreadInfoInput, ): ?ThreadInfo { const { sourceMessage, loggedInUserInfo, chatMentionCandidates } = input; const threadCreatedFromMessage = sourceMessage.threadCreatedFromMessage; if (threadCreatedFromMessage) { return threadCreatedFromMessage; } if (!loggedInUserInfo) { return null; } const { messageInfo, threadInfo } = sourceMessage; return createUnresolvedPendingSidebar({ sourceMessageInfo: messageInfo, parentThreadInfo: threadInfo, loggedInUserInfo, markdownRules: getDefaultTextMessageRules(chatMentionCandidates) .simpleMarkdownRules, }); } type GetSidebarThreadInfoInput = { ...GetUnresolvedSidebarThreadInfoInput, +getENSNames: ?GetENSNames, +getFCNames: ?GetFCNames, }; async function getSidebarThreadInfo( input: GetSidebarThreadInfoInput, ): Promise { const { sourceMessage, loggedInUserInfo, getENSNames, getFCNames, chatMentionCandidates, } = input; const threadCreatedFromMessage = sourceMessage.threadCreatedFromMessage; if (threadCreatedFromMessage) { return threadCreatedFromMessage; } if (!loggedInUserInfo) { return null; } const { messageInfo, threadInfo } = sourceMessage; return await createPendingSidebar({ sourceMessageInfo: messageInfo, parentThreadInfo: threadInfo, loggedInUserInfo, markdownRules: getDefaultTextMessageRules(chatMentionCandidates) .simpleMarkdownRules, getENSNames, getFCNames, }); } function useNavigateToSidebar( item: ChatMessageInfoItemWithHeight, ): () => mixed { const loggedInUserInfo = useLoggedInUserInfo(); const navigateToThread = useNavigateToThread(); const { getENSNames } = React.useContext(ENSCacheContext); const chatMentionCandidates = useThreadChatMentionCandidates(item.threadInfo); const neynarClientContext = React.useContext(NeynarClientContext); const getFCNames = neynarClientContext?.getFCNames; return React.useCallback(async () => { const threadInfo = await getSidebarThreadInfo({ sourceMessage: item, loggedInUserInfo, getENSNames, getFCNames, chatMentionCandidates, }); invariant(threadInfo, 'threadInfo should be set'); navigateToThread({ threadInfo }); }, [ item, loggedInUserInfo, getENSNames, getFCNames, chatMentionCandidates, navigateToThread, ]); } function useAnimatedNavigateToSidebar( item: ChatMessageInfoItemWithHeight, ): () => void { const chatContext = React.useContext(ChatContext); const setSidebarSourceID = chatContext?.setCurrentTransitionSidebarSourceID; const navigateToSidebar = useNavigateToSidebar(item); const messageID = item.messageInfo.id; return React.useCallback(() => { setSidebarSourceID && setSidebarSourceID(messageID); navigateToSidebar(); }, [setSidebarSourceID, messageID, navigateToSidebar]); } export { getUnresolvedSidebarThreadInfo, useNavigateToSidebar, useAnimatedNavigateToSidebar, }; diff --git a/native/chat/text-message.react.js b/native/chat/text-message.react.js index 01506a8de..8401bab99 100644 --- a/native/chat/text-message.react.js +++ b/native/chat/text-message.react.js @@ -1,314 +1,312 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { messageKey } from 'lib/shared/message-utils.js'; -import { - useCanCreateSidebarFromMessage, - useThreadHasPermission, -} from 'lib/shared/thread-utils.js'; +import { useCanCreateSidebarFromMessage } from 'lib/shared/sidebar-utils.js'; +import { useThreadHasPermission } from 'lib/shared/thread-utils.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { ChatNavigationProp } from './chat.react.js'; import ComposedMessage from './composed-message.react.js'; import { InnerTextMessage } from './inner-text-message.react.js'; import { MessageEditingContext } from './message-editing-context.react.js'; import { MessagePressResponderContext, type MessagePressResponderContextType, } from './message-press-responder-context.js'; import textMessageSendFailed from './text-message-send-failed.js'; import { getMessageTooltipKey } from './utils.js'; import { ChatContext, type ChatContextType } from '../chat/chat-context.js'; import { MarkdownContext } from '../markdown/markdown-context.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { useCanEditMessageNative } from '../navigation/nav-selectors.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import { TextMessageTooltipModalRouteName } from '../navigation/route-names.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { fixedTooltipHeight } from '../tooltip/tooltip.react.js'; import type { ChatTextMessageInfoItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; type BaseProps = { ...React.ElementConfig, +item: ChatTextMessageInfoItemWithHeight, +navigation: | ChatNavigationProp<'MessageList'> | AppNavigationProp<'TogglePinModal'> | ChatNavigationProp<'PinnedMessagesScreen'> | ChatNavigationProp<'MessageSearch'>, +route: | NavigationRoute<'MessageList'> | NavigationRoute<'TogglePinModal'> | NavigationRoute<'PinnedMessagesScreen'> | NavigationRoute<'MessageSearch'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, +canTogglePins: boolean, +shouldDisplayPinIndicator: boolean, }; type Props = { ...BaseProps, // Redux state +canCreateSidebarFromMessage: boolean, // withOverlayContext +overlayContext: ?OverlayContextType, // ChatContext +chatContext: ?ChatContextType, // MarkdownContext +isLinkModalActive: boolean, +isUserProfileBottomSheetActive: boolean, +canEditMessage: boolean, +currentUserIsVoiced: boolean, }; class TextMessage extends React.PureComponent { message: ?React.ElementRef; messagePressResponderContext: MessagePressResponderContextType; constructor(props: Props) { super(props); this.messagePressResponderContext = { onPressMessage: this.onPress, }; } render(): React.Node { const { item, navigation, route, focused, toggleFocus, verticalBounds, shouldDisplayPinIndicator, overlayContext, chatContext, isLinkModalActive, isUserProfileBottomSheetActive, canCreateSidebarFromMessage, canEditMessage, canTogglePins, currentUserIsVoiced, ...viewProps } = this.props; let swipeOptions = 'none'; const canReply = this.canReply(); const canNavigateToSidebar = this.canNavigateToSidebar(); if (isLinkModalActive) { swipeOptions = 'none'; } else if (canReply && canNavigateToSidebar) { swipeOptions = 'both'; } else if (canReply) { swipeOptions = 'reply'; } else if (canNavigateToSidebar) { swipeOptions = 'sidebar'; } return ( ); } messageRef = (message: ?React.ElementRef) => { this.message = message; }; canReply(): boolean { return this.props.currentUserIsVoiced; } canNavigateToSidebar(): boolean { return ( !!this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ); } visibleEntryIDs(): $ReadOnlyArray { const result = ['copy']; if (this.canReply()) { result.push('reply'); } if (this.props.canEditMessage) { result.push('edit'); } if (this.props.canTogglePins) { this.props.item.isPinned ? result.push('unpin') : result.push('pin'); } if ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ) { result.push('sidebar'); } if (!this.props.item.messageInfo.creator.isViewer) { result.push('report'); } return result; } onPress = () => { const visibleEntryIDs = this.visibleEntryIDs(); if (visibleEntryIDs.length === 0) { return; } const { message, props: { verticalBounds, isLinkModalActive, isUserProfileBottomSheetActive, }, } = this; if ( !message || !verticalBounds || isLinkModalActive || isUserProfileBottomSheetActive ) { return; } const { focused, toggleFocus, item } = this.props; if (!focused) { toggleFocus(messageKey(item.messageInfo)); } const { overlayContext } = this.props; invariant(overlayContext, 'TextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); message.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const messageTop = pageY; const messageBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = fixedTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = fixedTooltipHeight + aboveMargin; let margin = belowMargin; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { margin = aboveMargin; } const currentInputBarHeight = this.props.chatContext?.chatInputBarHeights.get(item.threadInfo.id) ?? 0; this.props.navigation.navigate<'TextMessageTooltipModal'>({ name: TextMessageTooltipModalRouteName, params: { presentedFrom: this.props.route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs, tooltipLocation: 'fixed', margin, item, chatInputBarHeight: currentInputBarHeight, }, key: getMessageTooltipKey(item), }); }); }; } const ConnectedTextMessage: React.ComponentType = React.memo(function ConnectedTextMessage(props: BaseProps) { const overlayContext = React.useContext(OverlayContext); const chatContext = React.useContext(ChatContext); const markdownContext = React.useContext(MarkdownContext); invariant(markdownContext, 'markdownContext should be set'); const { linkModalActive, userProfileBottomSheetActive, clearMarkdownContextData, } = markdownContext; const key = messageKey(props.item.messageInfo); // We check if there is an key in the object - if not, we // default to false. The likely situation where the former statement // evaluates to null is when the thread is opened for the first time. const isLinkModalActive = linkModalActive[key] ?? false; const isUserProfileBottomSheetActive = userProfileBottomSheetActive[key] ?? false; const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( props.item.threadInfo, props.item.messageInfo, ); const messageEditingContext = React.useContext(MessageEditingContext); const editMessageID = messageEditingContext?.editState.editedMessage?.id; const isThisMessageEdited = editMessageID === props.item.messageInfo.id; const canEditMessage = useCanEditMessageNative(props.item.threadInfo, props.item.messageInfo) && !isThisMessageEdited; React.useEffect(() => clearMarkdownContextData, [clearMarkdownContextData]); const currentUserCanReply = useThreadHasPermission( props.item.threadInfo, threadPermissions.VOICED, ); return ( ); }); export { ConnectedTextMessage as TextMessage }; diff --git a/web/selectors/thread-selectors.js b/web/selectors/thread-selectors.js index eb10a28ff..01b40c04b 100644 --- a/web/selectors/thread-selectors.js +++ b/web/selectors/thread-selectors.js @@ -1,165 +1,163 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { createSelector } from 'reselect'; import { ENSCacheContext } from 'lib/components/ens-cache-provider.react.js'; import { NeynarClientContext } from 'lib/components/neynar-client-provider.react.js'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; -import { - createPendingSidebar, - threadInHomeChatList, -} from 'lib/shared/thread-utils.js'; +import { createPendingSidebar } from 'lib/shared/sidebar-utils.js'; +import { threadInHomeChatList } from 'lib/shared/thread-utils.js'; import type { ComposableMessageInfo, RobotextMessageInfo, } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { RawThreadInfos } from 'lib/types/thread-types.js'; import { values } from 'lib/utils/objects.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import type { AppState } from '../redux/redux-setup.js'; import { useSelector } from '../redux/redux-utils.js'; function useOnClickThread( thread: ?ThreadInfo, ): (event: SyntheticEvent) => void { const dispatch = useDispatch(); return React.useCallback( (event: SyntheticEvent) => { invariant( thread?.id, 'useOnClickThread should be called with threadID set', ); event.preventDefault(); const { id: threadID } = thread; let payload; if (threadID.includes('pending')) { payload = { chatMode: 'view', activeChatThreadID: threadID, pendingThread: thread, tab: 'chat', }; } else { payload = { chatMode: 'view', activeChatThreadID: threadID, tab: 'chat', }; } dispatch({ type: updateNavInfoActionType, payload }); }, [dispatch, thread], ); } function useThreadIsActive(threadID: string): boolean { return useSelector(state => threadID === state.navInfo.activeChatThreadID); } function useOnClickPendingSidebar( messageInfo: ComposableMessageInfo | RobotextMessageInfo, threadInfo: ThreadInfo, ): (event: SyntheticEvent) => mixed { const dispatch = useDispatch(); const loggedInUserInfo = useLoggedInUserInfo(); const { getENSNames } = React.useContext(ENSCacheContext); const neynarClientContext = React.useContext(NeynarClientContext); const getFCNames = neynarClientContext?.getFCNames; const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); return React.useCallback( async (event: SyntheticEvent) => { event.preventDefault(); if (!loggedInUserInfo) { return; } const pendingSidebarInfo = await createPendingSidebar({ sourceMessageInfo: messageInfo, parentThreadInfo: threadInfo, loggedInUserInfo, markdownRules: getDefaultTextMessageRules(chatMentionCandidates) .simpleMarkdownRules, getENSNames, getFCNames, }); dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: pendingSidebarInfo.id, pendingThread: pendingSidebarInfo, }, }); }, [ loggedInUserInfo, chatMentionCandidates, threadInfo, messageInfo, getENSNames, getFCNames, dispatch, ], ); } function useOnClickNewThread(): (event: SyntheticEvent) => void { const dispatch = useDispatch(); return React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'create', selectedUserList: [], }, }); }, [dispatch], ); } function useDrawerSelectedThreadID(): ?string { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const pickedCommunityID = useSelector( state => state.communityPickerStore.calendar, ); const inCalendar = useSelector(state => state.navInfo.tab === 'calendar'); return inCalendar ? pickedCommunityID : activeChatThreadID; } const unreadCountInSelectedCommunity: (state: AppState) => number = createSelector( (state: AppState) => state.threadStore.threadInfos, (state: AppState) => state.communityPickerStore.chat, (threadInfos: RawThreadInfos, communityID: ?string): number => values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread && (!communityID || communityID === threadInfo.community), ).length, ); export { useOnClickThread, useThreadIsActive, useOnClickPendingSidebar, useOnClickNewThread, useDrawerSelectedThreadID, unreadCountInSelectedCommunity, }; diff --git a/web/tooltips/tooltip-action-utils.js b/web/tooltips/tooltip-action-utils.js index f26b742d6..2fafe41e5 100644 --- a/web/tooltips/tooltip-action-utils.js +++ b/web/tooltips/tooltip-action-utils.js @@ -1,558 +1,556 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { useResettingState } from 'lib/hooks/use-resetting-state.js'; import type { ChatMessageInfoItem, ReactionInfo, } from 'lib/selectors/chat-selectors.js'; import { useCanEditMessage } from 'lib/shared/edit-messages-utils.js'; import { createMessageReply } from 'lib/shared/message-utils.js'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js'; -import { - useSidebarExistsOrCanBeCreated, - useThreadHasPermission, -} from 'lib/shared/thread-utils.js'; +import { useSidebarExistsOrCanBeCreated } from 'lib/shared/sidebar-utils.js'; +import { useThreadHasPermission } from 'lib/shared/thread-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { longAbsoluteDate } from 'lib/utils/date-utils.js'; import { useCanToggleMessagePin } from 'lib/utils/message-pinning-utils.js'; import LabelTooltip from './label-toolitp.react.js'; import MessageTooltip from './message-tooltip.react.js'; import ReactionTooltip from './reaction-tooltip.react.js'; import { useTooltipContext } from './tooltip-provider.js'; import { calculateLabelTooltipSize, calculateMessageTooltipSize, calculateReactionTooltipSize, getTooltipPositionStyle, type MessageTooltipAction, type TooltipPosition, type TooltipPositionStyle, type TooltipSize, } from './tooltip-utils.js'; import { getComposedMessageID } from '../chat/chat-constants.js'; import { useEditModalContext } from '../chat/edit-message-provider.js'; import type { PositionInfo } from '../chat/position-types.js'; import CommIcon from '../comm-icon.react.js'; import { InputStateContext } from '../input/input-state.js'; import TogglePinModal from '../modals/chat/toggle-pin-modal.react.js'; import { useOnClickPendingSidebar, useOnClickThread, } from '../selectors/thread-selectors.js'; type UseTooltipArgs = { +createTooltip: (tooltipPositionStyle: TooltipPositionStyle) => React.Node, +tooltipSize: TooltipSize, +availablePositions: $ReadOnlyArray, }; type UseTooltipResult = { +onMouseEnter: (event: SyntheticEvent) => mixed, +onMouseLeave: ?() => mixed, }; function useTooltip({ createTooltip, tooltipSize, availablePositions, }: UseTooltipArgs): UseTooltipResult { const [onMouseLeave, setOnMouseLeave] = React.useState mixed>(null); const [tooltipSourcePosition, setTooltipSourcePosition] = React.useState(); const { renderTooltip } = useTooltipContext(); const updateTooltip = React.useRef mixed>(); const onMouseEnter = React.useCallback( (event: SyntheticEvent) => { if (!renderTooltip) { return; } const rect = event.currentTarget.getBoundingClientRect(); const { top, bottom, left, right, height, width } = rect; const sourcePosition = { top, bottom, left, right, height, width }; setTooltipSourcePosition(sourcePosition); const tooltipPositionStyle = getTooltipPositionStyle({ tooltipSourcePosition: sourcePosition, tooltipSize, availablePositions, }); if (!tooltipPositionStyle) { return; } const tooltip = createTooltip(tooltipPositionStyle); const renderTooltipResult = renderTooltip({ newNode: tooltip, tooltipPositionStyle, }); if (renderTooltipResult) { const { onMouseLeaveCallback: callback } = renderTooltipResult; setOnMouseLeave((() => callback: () => () => mixed)); updateTooltip.current = renderTooltipResult.updateTooltip; } }, [availablePositions, createTooltip, renderTooltip, tooltipSize], ); React.useEffect(() => { if (!updateTooltip.current) { return; } const tooltipPositionStyle = getTooltipPositionStyle({ tooltipSourcePosition, tooltipSize, availablePositions, }); if (!tooltipPositionStyle) { return; } const tooltip = createTooltip(tooltipPositionStyle); updateTooltip.current?.(tooltip); }, [availablePositions, createTooltip, tooltipSize, tooltipSourcePosition]); return { onMouseEnter, onMouseLeave, }; } function useMessageTooltipSidebarAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { threadCreatedFromMessage, messageInfo } = item; const { popModal } = useModalContext(); const sidebarExists = !!threadCreatedFromMessage; const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( threadInfo, item, ); const openThread = useOnClickThread(threadCreatedFromMessage); const openPendingSidebar = useOnClickPendingSidebar(messageInfo, threadInfo); return React.useMemo(() => { if (!sidebarExistsOrCanBeCreated) { return null; } const buttonContent = ; const onClick = (event: SyntheticEvent) => { popModal(); if (threadCreatedFromMessage) { openThread(event); } else { openPendingSidebar(event); } }; return { actionButtonContent: buttonContent, onClick, label: sidebarExists ? 'Go to thread' : 'Create thread', }; }, [ popModal, openPendingSidebar, openThread, sidebarExists, sidebarExistsOrCanBeCreated, threadCreatedFromMessage, ]); } function useMessageTooltipReplyAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; const { popModal } = useModalContext(); const inputState = React.useContext(InputStateContext); invariant(inputState, 'inputState is required'); const { addReply } = inputState; const currentUserIsVoiced = useThreadHasPermission( threadInfo, threadPermissions.VOICED, ); return React.useMemo(() => { if (item.messageInfo.type !== messageTypes.TEXT || !currentUserIsVoiced) { return null; } const buttonContent = ; const onClick = () => { popModal(); if (!messageInfo.text) { return; } addReply(createMessageReply(messageInfo.text)); }; return { actionButtonContent: buttonContent, onClick, label: 'Reply', }; }, [ popModal, addReply, item.messageInfo.type, messageInfo, currentUserIsVoiced, ]); } const copiedMessageDurationMs = 2000; function useMessageCopyAction( item: ChatMessageInfoItem, ): ?MessageTooltipAction { const { messageInfo } = item; const [successful, setSuccessful] = useResettingState( false, copiedMessageDurationMs, ); return React.useMemo(() => { if (messageInfo.type !== messageTypes.TEXT) { return null; } const buttonContent = ; const onClick = async () => { try { await navigator.clipboard.writeText(messageInfo.text); setSuccessful(true); } catch (e) { setSuccessful(false); } }; return { actionButtonContent: buttonContent, onClick, label: successful ? 'Copied!' : 'Copy', }; }, [messageInfo.text, messageInfo.type, setSuccessful, successful]); } function useMessageReactAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; const { setShouldRenderEmojiKeyboard } = useTooltipContext(); const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); return React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } const buttonContent = ; const onClickReact = () => { if (!setShouldRenderEmojiKeyboard) { return; } setShouldRenderEmojiKeyboard(true); }; return { actionButtonContent: buttonContent, onClick: onClickReact, label: 'React', }; }, [canCreateReactionFromMessage, setShouldRenderEmojiKeyboard]); } function useMessageTogglePinAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { pushModal } = useModalContext(); const { messageInfo, isPinned } = item; const canTogglePin = useCanToggleMessagePin(messageInfo, threadInfo); const inputState = React.useContext(InputStateContext); return React.useMemo(() => { if (!canTogglePin) { return null; } const iconName = isPinned ? 'unpin' : 'pin'; const buttonContent = ; const onClickTogglePin = () => { pushModal( , ); }; return { actionButtonContent: buttonContent, onClick: onClickTogglePin, label: isPinned ? 'Unpin' : 'Pin', }; }, [canTogglePin, inputState, isPinned, pushModal, item, threadInfo]); } function useMessageEditAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; const canEditMessage = useCanEditMessage(threadInfo, messageInfo); const { renderEditModal, scrollToMessage } = useEditModalContext(); const { clearTooltip } = useTooltipContext(); return React.useMemo(() => { if (!canEditMessage) { return null; } const buttonContent = ; const onClickEdit = () => { const callback = (maxHeight: number) => renderEditModal({ messageInfo: item, threadInfo, isError: false, editedMessageDraft: messageInfo.text, maxHeight: maxHeight, }); clearTooltip(); scrollToMessage(getComposedMessageID(messageInfo), callback); }; return { actionButtonContent: buttonContent, onClick: onClickEdit, label: 'Edit', }; }, [ canEditMessage, clearTooltip, item, messageInfo, renderEditModal, scrollToMessage, threadInfo, ]); } function useMessageTooltipActions( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): $ReadOnlyArray { const sidebarAction = useMessageTooltipSidebarAction(item, threadInfo); const replyAction = useMessageTooltipReplyAction(item, threadInfo); const copyAction = useMessageCopyAction(item); const reactAction = useMessageReactAction(item, threadInfo); const togglePinAction = useMessageTogglePinAction(item, threadInfo); const editAction = useMessageEditAction(item, threadInfo); return React.useMemo( () => [ replyAction, sidebarAction, copyAction, reactAction, togglePinAction, editAction, ].filter(Boolean), [ replyAction, sidebarAction, copyAction, reactAction, togglePinAction, editAction, ], ); } const undefinedTooltipSize = { width: 0, height: 0, }; type UseMessageTooltipArgs = { +availablePositions: $ReadOnlyArray, +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, }; function useMessageTooltip({ availablePositions, item, threadInfo, }: UseMessageTooltipArgs): UseTooltipResult { const tooltipActions = useMessageTooltipActions(item, threadInfo); const messageTimestamp = React.useMemo(() => { const time = item.messageInfo.time; return longAbsoluteDate(time); }, [item.messageInfo.time]); const tooltipSize = React.useMemo(() => { if (typeof document === 'undefined') { return undefinedTooltipSize; } const tooltipLabels = tooltipActions.map(action => action.label); return calculateMessageTooltipSize({ tooltipLabels, timestamp: messageTimestamp, }); }, [messageTimestamp, tooltipActions]); const createMessageTooltip = React.useCallback( (tooltipPositionStyle: TooltipPositionStyle) => ( ), [item, messageTimestamp, threadInfo, tooltipActions, tooltipSize], ); const { onMouseEnter, onMouseLeave } = useTooltip({ createTooltip: createMessageTooltip, tooltipSize, availablePositions, }); return { onMouseEnter, onMouseLeave, }; } const useENSNamesOptions = { allAtOnce: true }; type UseReactionTooltipArgs = { +reaction: string, +reactions: ReactionInfo, +availablePositions: $ReadOnlyArray, }; function useReactionTooltip({ reaction, reactions, availablePositions, }: UseReactionTooltipArgs): UseTooltipResult { const { users } = reactions[reaction]; const resolvedUsers = useENSNames(users, useENSNamesOptions); const showSeeMoreText = resolvedUsers.length > 5; const usernamesToShow = resolvedUsers .map(user => user.username) .filter(Boolean) .slice(0, 5); const tooltipSize = React.useMemo(() => { if (typeof document === 'undefined') { return undefinedTooltipSize; } return calculateReactionTooltipSize(usernamesToShow, showSeeMoreText); }, [showSeeMoreText, usernamesToShow]); const createReactionTooltip = React.useCallback( () => ( ), [reactions, showSeeMoreText, usernamesToShow], ); const { onMouseEnter, onMouseLeave } = useTooltip({ createTooltip: createReactionTooltip, tooltipSize, availablePositions, }); return { onMouseEnter, onMouseLeave, }; } type UseLabelTooltipArgs = { +tooltipLabel: string, +position: TooltipPosition, // The margin size should be between the point of origin and // the base of the tooltip. The arrow is a "decoration" and // should not be considered when measuring the margin size. +tooltipMargin: number, }; function useLabelTooltip({ tooltipLabel, position, tooltipMargin, }: UseLabelTooltipArgs): UseTooltipResult { const tooltipSize = React.useMemo(() => { if (typeof document === 'undefined') { return undefinedTooltipSize; } return calculateLabelTooltipSize(tooltipLabel, position, tooltipMargin); }, [position, tooltipLabel, tooltipMargin]); const createLabelTooltip = React.useCallback( () => ( ), [position, tooltipLabel, tooltipMargin], ); const { onMouseEnter, onMouseLeave } = useTooltip({ createTooltip: createLabelTooltip, tooltipSize, availablePositions: [position], }); return { onMouseEnter, onMouseLeave, }; } export { useMessageTooltipSidebarAction, useMessageTooltipReplyAction, useMessageReactAction, useMessageTooltipActions, useMessageTooltip, useReactionTooltip, useLabelTooltip, };