diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index 201a15bfc..a9cd2d0bb 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,717 +1,717 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _map from 'lodash/fp/map.js'; import _orderBy from 'lodash/fp/orderBy.js'; import * as React from 'react'; import { useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { threadInfoSelector, sidebarInfoSelector, threadInfoFromSourceMessageIDSelector, } from './thread-selectors.js'; import { messageKey, robotextForMessageInfo, createMessageInfo, getMostRecentNonLocalMessageID, sortMessageInfoList, } from '../shared/message-utils.js'; import { threadIsPending, threadIsTopLevel, threadInChatList, } from '../shared/thread-utils.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type MessageInfo, type MessageStore, type ComposableMessageInfo, type RobotextMessageInfo, type LocalMessageInfo, isComposableMessageType, } from '../types/message-types.js'; import type { MinimallyEncodedRawThreadInfo, MinimallyEncodedThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import { type ThreadInfo, type RawThreadInfo, type SidebarInfo, maxReadSidebars, maxUnreadSidebars, } from '../types/thread-types.js'; import type { UserInfo, AccountUserInfo, RelativeUserInfo, } from '../types/user-types.js'; import { threeDays } from '../utils/date-utils.js'; import type { EntityText } from '../utils/entity-text.js'; import memoize2 from '../utils/memoize.js'; export type SidebarItem = | { ...SidebarInfo, +type: 'sidebar', } | { +type: 'seeMore', +unread: boolean, } | { +type: 'spacer' }; export type ChatThreadItem = { +type: 'chatThreadItem', +threadInfo: ThreadInfo, +mostRecentMessageInfo: ?MessageInfo, +mostRecentNonLocalMessage: ?string, +lastUpdatedTime: number, +lastUpdatedTimeIncludingSidebars: number, +sidebars: $ReadOnlyArray, +pendingPersonalThreadUserInfo?: UserInfo, }; const messageInfoSelector: (state: BaseAppState<>) => { +[id: string]: ?MessageInfo, } = createObjectSelector( (state: BaseAppState<>) => state.messageStore.messages, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.userStore.userInfos, threadInfoSelector, createMessageInfo, ); function isEmptyMediaMessage(messageInfo: MessageInfo): boolean { return ( (messageInfo.type === messageTypes.MULTIMEDIA || messageInfo.type === messageTypes.IMAGES) && messageInfo.media.length === 0 ); } function getMostRecentMessageInfo( threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, messageStore: MessageStore, messages: { +[id: string]: ?MessageInfo }, ): ?MessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (const messageID of thread.messageIDs) { const messageInfo = messages[messageID]; if (!messageInfo || isEmptyMediaMessage(messageInfo)) { continue; } return messageInfo; } return null; } function getLastUpdatedTime( threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, mostRecentMessageInfo: ?MessageInfo, ): number { return mostRecentMessageInfo ? mostRecentMessageInfo.time : threadInfo.creationTime; } function createChatThreadItem( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { +[id: string]: ?MessageInfo }, sidebarInfos: ?$ReadOnlyArray, ): ChatThreadItem { const mostRecentMessageInfo = getMostRecentMessageInfo( threadInfo, messageStore, messages, ); const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( threadInfo.id, messageStore, ); const lastUpdatedTime = getLastUpdatedTime(threadInfo, mostRecentMessageInfo); const sidebars = sidebarInfos ?? []; const allSidebarItems = sidebars.map(sidebarInfo => ({ type: 'sidebar', ...sidebarInfo, })); const lastUpdatedTimeIncludingSidebars = allSidebarItems.length > 0 ? Math.max(lastUpdatedTime, allSidebarItems[0].lastUpdatedTime) : lastUpdatedTime; const numUnreadSidebars = allSidebarItems.filter( sidebar => sidebar.threadInfo.currentUser.unread, ).length; let numReadSidebarsToShow = maxReadSidebars - numUnreadSidebars; const threeDaysAgo = Date.now() - threeDays; const sidebarItems = []; for (const sidebar of allSidebarItems) { if (sidebarItems.length >= maxUnreadSidebars) { break; } else if (sidebar.threadInfo.currentUser.unread) { sidebarItems.push(sidebar); } else if ( sidebar.lastUpdatedTime > threeDaysAgo && numReadSidebarsToShow > 0 ) { sidebarItems.push(sidebar); numReadSidebarsToShow--; } } const numReadButRecentSidebars = allSidebarItems.filter( sidebar => !sidebar.threadInfo.currentUser.unread && sidebar.lastUpdatedTime > threeDaysAgo, ).length; if ( sidebarItems.length < numUnreadSidebars + numReadButRecentSidebars || (sidebarItems.length < allSidebarItems.length && sidebarItems.length > 0) ) { sidebarItems.push({ type: 'seeMore', unread: numUnreadSidebars > maxUnreadSidebars, }); } if (sidebarItems.length !== 0) { sidebarItems.push({ type: 'spacer', }); } return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo, mostRecentNonLocalMessage, lastUpdatedTime, lastUpdatedTimeIncludingSidebars, sidebars: sidebarItems, }; } const chatListData: (state: BaseAppState<>) => $ReadOnlyArray = createSelector( threadInfoSelector, (state: BaseAppState<>) => state.messageStore, messageInfoSelector, sidebarInfoSelector, ( threadInfos: { +[id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, sidebarInfos: { +[id: string]: $ReadOnlyArray }, ): $ReadOnlyArray => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, threadIsTopLevel, ), ); function useFlattenedChatListData(): $ReadOnlyArray { return useFilteredChatListData(threadInChatList); } function useFilteredChatListData( filterFunction: ( threadInfo: ?( | ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo | MinimallyEncodedRawThreadInfo ), ) => boolean, ): $ReadOnlyArray { const threadInfos = useSelector(threadInfoSelector); const messageInfos = useSelector(messageInfoSelector); const sidebarInfos = useSelector(sidebarInfoSelector); const messageStore = useSelector(state => state.messageStore); return React.useMemo( () => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, filterFunction, ), [messageInfos, messageStore, sidebarInfos, filterFunction, threadInfos], ); } function getChatThreadItems( threadInfos: { +[id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, sidebarInfos: { +[id: string]: $ReadOnlyArray }, filterFunction: ( threadInfo: ?( | ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo | MinimallyEncodedRawThreadInfo ), ) => boolean, ): $ReadOnlyArray { return _flow( _filter(filterFunction), _map((threadInfo: ThreadInfo): ChatThreadItem => createChatThreadItem( threadInfo, messageStore, messageInfos, sidebarInfos[threadInfo.id], ), ), _orderBy('lastUpdatedTimeIncludingSidebars')('desc'), )(threadInfos); } export type RobotextChatMessageInfoItem = { +itemType: 'message', +messageInfoType: 'robotext', +messageInfo: RobotextMessageInfo, +startsConversation: boolean, +startsCluster: boolean, endsCluster: boolean, +robotext: EntityText, - +threadCreatedFromMessage: ?ThreadInfo, + +threadCreatedFromMessage: ?ThreadInfo | ?MinimallyEncodedThreadInfo, +reactions: ReactionInfo, }; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem | { +itemType: 'message', +messageInfoType: 'composable', +messageInfo: ComposableMessageInfo, +localMessageInfo: ?LocalMessageInfo, +startsConversation: boolean, +startsCluster: boolean, endsCluster: boolean, - +threadCreatedFromMessage: ?ThreadInfo, + +threadCreatedFromMessage: ?ThreadInfo | ?MinimallyEncodedThreadInfo, +reactions: ReactionInfo, +hasBeenEdited: boolean, +isPinned: boolean, }; export type ChatMessageItem = { itemType: 'loader' } | ChatMessageInfoItem; export type ReactionInfo = { +[reaction: string]: MessageReactionInfo }; type MessageReactionInfo = { +viewerReacted: boolean, +users: $ReadOnlyArray, }; type TargetMessageReactions = Map>; const msInFiveMinutes = 5 * 60 * 1000; function createChatMessageItems( threadID: string, messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, threadInfos: { +[id: string]: ThreadInfo }, threadInfoFromSourceMessageID: { +[id: string]: ThreadInfo }, additionalMessages: $ReadOnlyArray, viewerID: string, ): ChatMessageItem[] { const thread = messageStore.threads[threadID]; const threadMessageInfos = (thread?.messageIDs ?? []) .map((messageID: string) => messageInfos[messageID]) .filter(Boolean); const messages = additionalMessages.length > 0 ? sortMessageInfoList([...threadMessageInfos, ...additionalMessages]) : threadMessageInfos; const targetMessageReactionsMap = new Map(); // We need to iterate backwards to put the order of messages in chronological // order, starting with the oldest. This avoids the scenario where the most // recent message with the remove_reaction action may try to remove a user // that hasn't been added to the messageReactionUsersInfoMap, causing it // to be skipped. for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if (messageInfo.type !== messageTypes.REACTION) { continue; } if (!targetMessageReactionsMap.has(messageInfo.targetMessageID)) { const reactsMap: TargetMessageReactions = new Map(); targetMessageReactionsMap.set(messageInfo.targetMessageID, reactsMap); } const messageReactsMap = targetMessageReactionsMap.get( messageInfo.targetMessageID, ); invariant(messageReactsMap, 'messageReactsInfo should be set'); if (!messageReactsMap.has(messageInfo.reaction)) { const usersInfoMap = new Map(); messageReactsMap.set(messageInfo.reaction, usersInfoMap); } const messageReactionUsersInfoMap = messageReactsMap.get( messageInfo.reaction, ); invariant( messageReactionUsersInfoMap, 'messageReactionUsersInfoMap should be set', ); if (messageInfo.action === 'add_reaction') { messageReactionUsersInfoMap.set( messageInfo.creator.id, messageInfo.creator, ); } else { messageReactionUsersInfoMap.delete(messageInfo.creator.id); } } const targetMessageEditMap = new Map(); for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if (messageInfo.type !== messageTypes.EDIT_MESSAGE) { continue; } targetMessageEditMap.set(messageInfo.targetMessageID, messageInfo.text); } const targetMessagePinStatusMap = new Map(); // Once again, we iterate backwards to put the order of messages in // chronological order (i.e. oldest to newest) to handle pinned messages. // This is important because we want to make sure that the most recent pin // action is the one that is used to determine whether a message // is pinned or not. for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if (messageInfo.type !== messageTypes.TOGGLE_PIN) { continue; } targetMessagePinStatusMap.set( messageInfo.targetMessageID, messageInfo.action === 'pin', ); } const chatMessageItems = []; let lastMessageInfo = null; for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if ( messageInfo.type === messageTypes.REACTION || messageInfo.type === messageTypes.EDIT_MESSAGE ) { continue; } let originalMessageInfo = messageInfo.type === messageTypes.SIDEBAR_SOURCE ? messageInfo.sourceMessage : messageInfo; if (isEmptyMediaMessage(originalMessageInfo)) { continue; } let hasBeenEdited = false; if ( originalMessageInfo.type === messageTypes.TEXT && originalMessageInfo.id ) { const newText = targetMessageEditMap.get(originalMessageInfo.id); if (newText !== undefined) { hasBeenEdited = true; originalMessageInfo = { ...originalMessageInfo, text: newText, }; } } let startsConversation = true; let startsCluster = true; if ( lastMessageInfo && lastMessageInfo.time + msInFiveMinutes > originalMessageInfo.time ) { startsConversation = false; if ( isComposableMessageType(lastMessageInfo.type) && isComposableMessageType(originalMessageInfo.type) && lastMessageInfo.creator.id === originalMessageInfo.creator.id ) { startsCluster = false; } } if (startsCluster && chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } const threadCreatedFromMessage = messageInfo.id && threadInfos[threadID]?.type !== threadTypes.SIDEBAR ? threadInfoFromSourceMessageID[messageInfo.id] : undefined; const isPinned = !!( originalMessageInfo.id && targetMessagePinStatusMap.get(originalMessageInfo.id) ); const renderedReactions: ReactionInfo = (() => { const result = {}; let messageReactsMap; if (originalMessageInfo.id) { messageReactsMap = targetMessageReactionsMap.get( originalMessageInfo.id, ); } if (!messageReactsMap) { return result; } for (const reaction of messageReactsMap.keys()) { const reactionUsersInfoMap = messageReactsMap.get(reaction); invariant(reactionUsersInfoMap, 'reactionUsersInfoMap should be set'); if (reactionUsersInfoMap.size === 0) { continue; } const reactionUserInfos = [...reactionUsersInfoMap.values()]; const messageReactionInfo = { users: reactionUserInfos, viewerReacted: reactionUsersInfoMap.has(viewerID), }; result[reaction] = messageReactionInfo; } return result; })(); if (isComposableMessageType(originalMessageInfo.type)) { // We use these invariants instead of just checking the messageInfo.type // directly in the conditional above so that isComposableMessageType can // be the source of truth invariant( originalMessageInfo.type === messageTypes.TEXT || originalMessageInfo.type === messageTypes.IMAGES || originalMessageInfo.type === messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const localMessageInfo = messageStore.local[messageKey(originalMessageInfo)]; chatMessageItems.push({ itemType: 'message', messageInfoType: 'composable', messageInfo: originalMessageInfo, localMessageInfo, startsConversation, startsCluster, endsCluster: false, threadCreatedFromMessage, reactions: renderedReactions, hasBeenEdited, isPinned, }); } else { invariant( originalMessageInfo.type !== messageTypes.TEXT && originalMessageInfo.type !== messageTypes.IMAGES && originalMessageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const threadInfo = threadInfos[threadID]; const parentThreadInfo = threadInfo?.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; const robotext = robotextForMessageInfo( originalMessageInfo, threadInfo, parentThreadInfo, ); chatMessageItems.push({ itemType: 'message', messageInfoType: 'robotext', messageInfo: originalMessageInfo, startsConversation, startsCluster, endsCluster: false, threadCreatedFromMessage, robotext, reactions: renderedReactions, }); } lastMessageInfo = originalMessageInfo; } if (chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } chatMessageItems.reverse(); const hideSpinner = thread ? thread.startReached : threadIsPending(threadID); if (hideSpinner) { return chatMessageItems; } return [...chatMessageItems, ({ itemType: 'loader' }: ChatMessageItem)]; } const baseMessageListData = ( threadID: ?string, additionalMessages: $ReadOnlyArray, ) => createSelector( (state: BaseAppState<>) => state.messageStore, messageInfoSelector, threadInfoSelector, threadInfoFromSourceMessageIDSelector, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, ( messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, threadInfos: { +[id: string]: ThreadInfo }, threadInfoFromSourceMessageID: { +[id: string]: ThreadInfo }, viewerID: ?string, ): ?(ChatMessageItem[]) => { if (!threadID || !viewerID) { return null; } return createChatMessageItems( threadID, messageStore, messageInfos, threadInfos, threadInfoFromSourceMessageID, additionalMessages, viewerID, ); }, ); export type MessageListData = ?(ChatMessageItem[]); const messageListData: ( threadID: ?string, additionalMessages: $ReadOnlyArray, ) => (state: BaseAppState<>) => MessageListData = memoize2(baseMessageListData); export type UseMessageListDataArgs = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, +threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, }; function useMessageListData({ searching, userInfoInputArray, threadInfo, }: UseMessageListDataArgs): MessageListData { const messageInfos = useSelector(messageInfoSelector); const containingThread = useSelector(state => { if (!threadInfo || threadInfo.type !== threadTypes.SIDEBAR) { return null; } return state.messageStore.threads[threadInfo.containingThreadID]; }); const pendingSidebarEditMessageInfo = React.useMemo(() => { const sourceMessageID = threadInfo?.sourceMessageID; const threadMessageInfos = (containingThread?.messageIDs ?? []) .map((messageID: string) => messageInfos[messageID]) .filter(Boolean) .filter( message => message.type === messageTypes.EDIT_MESSAGE && message.targetMessageID === sourceMessageID, ); if (threadMessageInfos.length === 0) { return null; } return threadMessageInfos[0]; }, [threadInfo, containingThread, messageInfos]); const pendingSidebarSourceMessageInfo = useSelector(state => { const sourceMessageID = threadInfo?.sourceMessageID; if ( !threadInfo || threadInfo.type !== threadTypes.SIDEBAR || !sourceMessageID ) { return null; } const thread = state.messageStore.threads[threadInfo.id]; const shouldSourceBeAdded = !thread || (thread.startReached && thread.messageIDs.every( id => messageInfos[id]?.type !== messageTypes.SIDEBAR_SOURCE, )); return shouldSourceBeAdded ? messageInfos[sourceMessageID] : null; }); invariant( !pendingSidebarSourceMessageInfo || pendingSidebarSourceMessageInfo.type !== messageTypes.SIDEBAR_SOURCE, 'sidebars can not be created from sidebar_source message', ); const additionalMessages = React.useMemo(() => { if (!pendingSidebarSourceMessageInfo) { return []; } const result = [pendingSidebarSourceMessageInfo]; if (pendingSidebarEditMessageInfo) { result.push(pendingSidebarEditMessageInfo); } return result; }, [pendingSidebarSourceMessageInfo, pendingSidebarEditMessageInfo]); const boundMessageListData = useSelector( messageListData(threadInfo?.id, additionalMessages), ); return React.useMemo(() => { if (searching && userInfoInputArray.length === 0) { return []; } return boundMessageListData; }, [searching, userInfoInputArray.length, boundMessageListData]); } export { messageInfoSelector, createChatThreadItem, chatListData, createChatMessageItems, messageListData, useFlattenedChatListData, useFilteredChatListData, useMessageListData, }; diff --git a/lib/shared/mention-utils.js b/lib/shared/mention-utils.js index a56691a91..b9645ecfa 100644 --- a/lib/shared/mention-utils.js +++ b/lib/shared/mention-utils.js @@ -1,193 +1,199 @@ // @flow import { oldValidUsernameRegexString } from './account-utils.js'; import SentencePrefixSearchIndex from './sentence-prefix-search-index.js'; import { threadOtherMembers } from './thread-utils.js'; import { stringForUserExplicit } from './user-utils.js'; -import type { MinimallyEncodedResolvedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; +import type { + MinimallyEncodedRelativeMemberInfo, + MinimallyEncodedResolvedThreadInfo, + MinimallyEncodedThreadInfo, +} from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { ThreadInfo, RelativeMemberInfo, ResolvedThreadInfo, ChatMentionCandidates, } from '../types/thread-types.js'; import { idSchemaRegex, chatNameMaxLength } from '../utils/validation-utils.js'; export type TypeaheadMatchedStrings = { +textBeforeAtSymbol: string, +query: string, }; export type Selection = { +start: number, +end: number, }; type MentionTypeaheadUserSuggestionItem = { +type: 'user', - +userInfo: RelativeMemberInfo, + +userInfo: RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo, }; type MentionTypeaheadChatSuggestionItem = { +type: 'chat', +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, }; export type MentionTypeaheadSuggestionItem = | MentionTypeaheadUserSuggestionItem | MentionTypeaheadChatSuggestionItem; export type TypeaheadTooltipActionItem = { +key: string, +execute: () => mixed, +actionButtonContent: SuggestionItemType, }; // The simple-markdown package already breaks words out for us, and we are // supposed to only match when the first word of the input matches const markdownUserMentionRegex: RegExp = new RegExp( `^(@(${oldValidUsernameRegexString}))\\b`, ); function isUserMentioned(username: string, text: string): boolean { return new RegExp(`\\B@${username}\\b`, 'i').test(text); } const userMentionsExtractionRegex = new RegExp( `\\B(@(${oldValidUsernameRegexString}))\\b`, 'g', ); const chatMentionRegexString = `([^\\\\]|^)(@\\[\\[(${idSchemaRegex}):((.{0,${chatNameMaxLength}}?)(?!\\\\).|^)\\]\\])`; const chatMentionRegex: RegExp = new RegExp(`^${chatMentionRegexString}`); const globalChatMentionRegex: RegExp = new RegExp(chatMentionRegexString, 'g'); function encodeChatMentionText(text: string): string { return text.replace(/]/g, '\\]'); } function decodeChatMentionText(text: string): string { return text.replace(/\\]/g, ']'); } function getRawChatMention( threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, ): string { return `@[[${threadInfo.id}:${encodeChatMentionText(threadInfo.uiName)}]]`; } function renderChatMentionsWithAltText(text: string): string { return text.replace( globalChatMentionRegex, (...match) => `${match[1]}@${decodeChatMentionText(match[4])}`, ); } function extractUserMentionsFromText(text: string): string[] { const iterator = text.matchAll(userMentionsExtractionRegex); return [...iterator].map(matches => matches[2]); } function getTypeaheadRegexMatches( text: string, selection: Selection, regex: RegExp, ): null | RegExp$matchResult { if ( selection.start === selection.end && (selection.start === text.length || /\s/.test(text[selection.end])) ) { return text.slice(0, selection.start).match(regex); } return null; } function getMentionTypeaheadUserSuggestions( userSearchIndex: SentencePrefixSearchIndex, - threadMembers: $ReadOnlyArray, + threadMembers: $ReadOnlyArray< + RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo, + >, viewerID: ?string, usernamePrefix: string, ): $ReadOnlyArray { const userIDs = userSearchIndex.getSearchResults(usernamePrefix); const usersInThread = threadOtherMembers(threadMembers, viewerID); return usersInThread .filter(user => usernamePrefix.length === 0 || userIDs.includes(user.id)) .sort((userA, userB) => stringForUserExplicit(userA).localeCompare(stringForUserExplicit(userB)), ) .map(userInfo => ({ type: 'user', userInfo })); } function getMentionTypeaheadChatSuggestions( chatSearchIndex: SentencePrefixSearchIndex, chatMentionCandidates: ChatMentionCandidates, chatNamePrefix: string, ): $ReadOnlyArray { const result = []; const threadIDs = chatSearchIndex.getSearchResults(chatNamePrefix); for (const threadID of threadIDs) { if (!chatMentionCandidates[threadID]) { continue; } result.push({ type: 'chat', threadInfo: chatMentionCandidates[threadID], }); } return result; } function getNewTextAndSelection( textBeforeAtSymbol: string, entireText: string, query: string, suggestionText: string, ): { newText: string, newSelectionStart: number, } { const totalMatchLength = textBeforeAtSymbol.length + query.length + 1; // 1 for @ char let newSuffixText = entireText.slice(totalMatchLength); newSuffixText = (newSuffixText[0] !== ' ' ? ' ' : '') + newSuffixText; const newText = textBeforeAtSymbol + suggestionText + newSuffixText; const newSelectionStart = newText.length - newSuffixText.length + 1; return { newText, newSelectionStart }; } function getUserMentionsCandidates( - threadInfo: ThreadInfo, - parentThreadInfo: ?ThreadInfo, -): $ReadOnlyArray { + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, +): $ReadOnlyArray { if (threadInfo.type !== threadTypes.SIDEBAR) { return threadInfo.members; } if (parentThreadInfo) { return parentThreadInfo.members; } // This scenario should not occur unless the user logs out while looking at a // sidebar. In that scenario, the Redux store may be cleared before ReactNav // finishes transitioning away from the previous screen return []; } export { markdownUserMentionRegex, isUserMentioned, extractUserMentionsFromText, getMentionTypeaheadUserSuggestions, getMentionTypeaheadChatSuggestions, getNewTextAndSelection, getTypeaheadRegexMatches, getUserMentionsCandidates, chatMentionRegex, encodeChatMentionText, decodeChatMentionText, getRawChatMention, renderChatMentionsWithAltText, }; diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index 9e6a1110d..ab16dc22c 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,735 +1,735 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy.js'; import _orderBy from 'lodash/fp/orderBy.js'; import * as React from 'react'; import { codeBlockRegex, type ParserRules } from './markdown.js'; import type { CreationSideEffectsFunc } from './messages/message-spec.js'; import { messageSpecs } from './messages/message-specs.js'; import { threadIsGroupChat } from './thread-utils.js'; import { useStringForUser } from '../hooks/ens-cache.js'; import { contentStringForMediaArray } from '../media/media-utils.js'; import type { ChatMessageInfoItem } from '../selectors/chat-selectors.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors.js'; import { type PlatformDetails, isWebPlatform } from '../types/device-types.js'; import type { Media } from '../types/media-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageTruncationStatus, type MultimediaMessageData, type MessageStore, type ComposableMessageInfo, messageTruncationStatus, type RawComposableMessageInfo, type ThreadMessageInfo, } from '../types/message-types.js'; import type { EditMessageInfo, RawEditMessageInfo, } from '../types/messages/edit.js'; import type { ImagesMessageData } from '../types/messages/images.js'; import type { MediaMessageData } from '../types/messages/media.js'; import type { RawReactionMessageInfo, ReactionMessageInfo, } from '../types/messages/reaction.js'; import type { MinimallyEncodedRawThreadInfo, MinimallyEncodedThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { RawThreadInfo, ThreadInfo } from '../types/thread-types.js'; import type { UserInfos } from '../types/user-types.js'; import { type EntityText, ET, useEntityTextAsString, } from '../utils/entity-text.js'; import { useSelector } from '../utils/redux-utils.js'; const localIDPrefix = 'local'; const defaultMediaMessageOptions = Object.freeze({}); // Prefers localID function messageKey(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.localID) { return messageInfo.localID; } invariant(messageInfo.id, 'localID should exist if ID does not'); return messageInfo.id; } // Prefers serverID function messageID(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.id) { return messageInfo.id; } invariant(messageInfo.localID, 'localID should exist if ID does not'); return messageInfo.localID; } function robotextForMessageInfo( messageInfo: RobotextMessageInfo, - threadInfo: ?ThreadInfo, - parentThreadInfo: ?ThreadInfo, + threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, ): EntityText { const messageSpec = messageSpecs[messageInfo.type]; invariant( messageSpec.robotext, `we're not aware of messageType ${messageInfo.type}`, ); return messageSpec.robotext(messageInfo, { threadInfo, parentThreadInfo }); } function createMessageInfo( rawMessageInfo: RawMessageInfo, viewerID: ?string, userInfos: UserInfos, threadInfos: { +[id: string]: ThreadInfo }, ): ?MessageInfo { const creatorInfo = userInfos[rawMessageInfo.creatorID]; const creator = { id: rawMessageInfo.creatorID, username: creatorInfo ? creatorInfo.username : 'anonymous', isViewer: rawMessageInfo.creatorID === viewerID, }; const createRelativeUserInfos = (userIDs: $ReadOnlyArray) => userIDsToRelativeUserInfos(userIDs, viewerID, userInfos); const createMessageInfoFromRaw = (rawInfo: RawMessageInfo) => createMessageInfo(rawInfo, viewerID, userInfos, threadInfos); const messageSpec = messageSpecs[rawMessageInfo.type]; return messageSpec.createMessageInfo(rawMessageInfo, creator, { threadInfos, createMessageInfoFromRaw, createRelativeUserInfos, }); } type LengthResult = { +local: number, +realized: number, }; function findMessageIDMaxLengths( messageIDs: $ReadOnlyArray, ): LengthResult { const result = { local: 0, realized: 0, }; for (const id of messageIDs) { if (!id) { continue; } if (id.startsWith(localIDPrefix)) { result.local = Math.max(result.local, id.length - localIDPrefix.length); } else { result.realized = Math.max(result.realized, id.length); } } return result; } function extendMessageID(id: ?string, lengths: LengthResult): ?string { if (!id) { return id; } if (id.startsWith(localIDPrefix)) { const zeroPaddedID = id .substr(localIDPrefix.length) .padStart(lengths.local, '0'); return `${localIDPrefix}${zeroPaddedID}`; } return id.padStart(lengths.realized, '0'); } function sortMessageInfoList( messageInfos: $ReadOnlyArray, ): T[] { const lengths = findMessageIDMaxLengths( messageInfos.map(message => message?.id), ); return _orderBy([ 'time', (message: T) => extendMessageID(message?.id, lengths), ])(['desc', 'desc'])(messageInfos); } const sortMessageIDs: (messages: { +[id: string]: RawMessageInfo }) => ( messageIDs: $ReadOnlyArray, ) => string[] = messages => messageIDs => { const lengths = findMessageIDMaxLengths(messageIDs); return _orderBy([ (id: string) => messages[id].time, (id: string) => extendMessageID(id, lengths), ])(['desc', 'desc'])(messageIDs); }; function rawMessageInfoFromMessageData( messageData: MessageData, id: ?string, ): RawMessageInfo { const messageSpec = messageSpecs[messageData.type]; invariant( messageSpec.rawMessageInfoFromMessageData, `we're not aware of messageType ${messageData.type}`, ); return messageSpec.rawMessageInfoFromMessageData(messageData, id); } function mostRecentMessageTimestamp( messageInfos: $ReadOnlyArray, previousTimestamp: number, ): number { if (messageInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(messageInfos).time; } function usersInMessageInfos( messageInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (const messageInfo of messageInfos) { if (messageInfo.creatorID) { userIDs.add(messageInfo.creatorID); } else if (messageInfo.creator) { userIDs.add(messageInfo.creator.id); } } return [...userIDs]; } function combineTruncationStatuses( first: MessageTruncationStatus, second: ?MessageTruncationStatus, ): MessageTruncationStatus { if ( first === messageTruncationStatus.EXHAUSTIVE || second === messageTruncationStatus.EXHAUSTIVE ) { return messageTruncationStatus.EXHAUSTIVE; } else if ( first === messageTruncationStatus.UNCHANGED && second !== null && second !== undefined ) { return second; } else { return first; } } function shimUnsupportedRawMessageInfos( rawMessageInfos: $ReadOnlyArray, platformDetails: ?PlatformDetails, ): RawMessageInfo[] { if (platformDetails && isWebPlatform(platformDetails.platform)) { return [...rawMessageInfos]; } return rawMessageInfos.map(rawMessageInfo => { const { shimUnsupportedMessageInfo } = messageSpecs[rawMessageInfo.type]; if (shimUnsupportedMessageInfo) { return shimUnsupportedMessageInfo(rawMessageInfo, platformDetails); } return rawMessageInfo; }); } type MediaMessageDataCreationInput = { +threadID: string, +creatorID: string, +media: $ReadOnlyArray, +localID?: ?string, +time?: ?number, +sidebarCreation?: ?boolean, ... }; function createMediaMessageData( input: MediaMessageDataCreationInput, options: { +forceMultimediaMessageType?: boolean, } = defaultMediaMessageOptions, ): MultimediaMessageData { let allMediaArePhotos = true; const photoMedia = []; for (const singleMedia of input.media) { if (singleMedia.type !== 'photo') { allMediaArePhotos = false; break; } else { photoMedia.push(singleMedia); } } const { localID, threadID, creatorID, sidebarCreation } = input; const { forceMultimediaMessageType = false } = options; const time = input.time ? input.time : Date.now(); let messageData; if (allMediaArePhotos && !forceMultimediaMessageType) { messageData = ({ type: messageTypes.IMAGES, threadID, creatorID, time, media: photoMedia, }: ImagesMessageData); if (localID) { messageData = { ...messageData, localID }; } if (sidebarCreation) { messageData = { ...messageData, sidebarCreation }; } } else { messageData = ({ type: messageTypes.MULTIMEDIA, threadID, creatorID, time, media: input.media, }: MediaMessageData); if (localID) { messageData = { ...messageData, localID }; } if (sidebarCreation) { messageData = { ...messageData, sidebarCreation }; } } return messageData; } type MediaMessageInfoCreationInput = { ...$Exact, +id?: ?string, }; function createMediaMessageInfo( input: MediaMessageInfoCreationInput, options: { +forceMultimediaMessageType?: boolean, } = defaultMediaMessageOptions, ): RawMultimediaMessageInfo { const messageData = createMediaMessageData(input, options); const createRawMessageInfo = messageSpecs[messageData.type].rawMessageInfoFromMessageData; invariant( createRawMessageInfo, 'multimedia message spec should have rawMessageInfoFromMessageData', ); const result = createRawMessageInfo(messageData, input.id); invariant( result.type === messageTypes.MULTIMEDIA || result.type === messageTypes.IMAGES, `media messageSpec returned MessageType ${result.type}`, ); return result; } function stripLocalID( rawMessageInfo: | RawComposableMessageInfo | RawReactionMessageInfo | RawEditMessageInfo, ) { const { localID, ...rest } = rawMessageInfo; return rest; } function stripLocalIDs( input: $ReadOnlyArray, ): RawMessageInfo[] { const output = []; for (const rawMessageInfo of input) { if (rawMessageInfo.localID) { invariant( rawMessageInfo.id, 'serverID should be set if localID is being stripped', ); output.push(stripLocalID(rawMessageInfo)); } else { output.push(rawMessageInfo); } } return output; } // Normally we call trim() to remove whitespace at the beginning and end of each // message. However, our Markdown parser supports a "codeBlock" format where the // user can indent each line to indicate a code block. If we match the // corresponding RegEx, we'll only trim whitespace off the end. function trimMessage(message: string): string { message = message.replace(/^\n*/, ''); return codeBlockRegex.exec(message) ? message.trimEnd() : message.trim(); } function createMessageQuote(message: string): string { // add `>` to each line to include empty lines in the quote return message.replace(/^/gm, '> '); } function createMessageReply(message: string): string { return createMessageQuote(message) + '\n\n'; } function getMostRecentNonLocalMessageID( threadID: string, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadID]; return thread?.messageIDs.find(id => !id.startsWith(localIDPrefix)); } function getOldestNonLocalMessageID( threadID: string, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadID]; if (!thread) { return thread; } const { messageIDs } = thread; for (let i = messageIDs.length - 1; i >= 0; i--) { const id = messageIDs[i]; if (!id.startsWith(localIDPrefix)) { return id; } } return undefined; } function getMessageTitle( messageInfo: | ComposableMessageInfo | RobotextMessageInfo | ReactionMessageInfo | EditMessageInfo, - threadInfo: ThreadInfo, - parentThreadInfo: ?ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, markdownRules: ParserRules, ): EntityText { const { messageTitle } = messageSpecs[messageInfo.type]; if (messageTitle) { return messageTitle({ messageInfo, threadInfo, markdownRules }); } invariant( messageInfo.type !== messageTypes.TEXT && messageInfo.type !== messageTypes.IMAGES && messageInfo.type !== messageTypes.MULTIMEDIA && messageInfo.type !== messageTypes.REACTION && messageInfo.type !== messageTypes.EDIT_MESSAGE, 'messageTitle can only be auto-generated for RobotextMessageInfo', ); return robotextForMessageInfo(messageInfo, threadInfo, parentThreadInfo); } function mergeThreadMessageInfos( first: ThreadMessageInfo, second: ThreadMessageInfo, messages: { +[id: string]: RawMessageInfo }, ): ThreadMessageInfo { let firstPointer = 0; let secondPointer = 0; const mergedMessageIDs = []; let firstCandidate = first.messageIDs[firstPointer]; let secondCandidate = second.messageIDs[secondPointer]; while (firstCandidate !== undefined || secondCandidate !== undefined) { if (firstCandidate === undefined) { mergedMessageIDs.push(secondCandidate); secondPointer++; } else if (secondCandidate === undefined) { mergedMessageIDs.push(firstCandidate); firstPointer++; } else if (firstCandidate === secondCandidate) { mergedMessageIDs.push(firstCandidate); firstPointer++; secondPointer++; } else { const firstMessage = messages[firstCandidate]; const secondMessage = messages[secondCandidate]; invariant( firstMessage && secondMessage, 'message in messageIDs not present in MessageStore', ); if ( (firstMessage.id && secondMessage.id && firstMessage.id === secondMessage.id) || (firstMessage.localID && secondMessage.localID && firstMessage.localID === secondMessage.localID) ) { mergedMessageIDs.push(firstCandidate); firstPointer++; secondPointer++; } else if (firstMessage.time < secondMessage.time) { mergedMessageIDs.push(secondCandidate); secondPointer++; } else { mergedMessageIDs.push(firstCandidate); firstPointer++; } } firstCandidate = first.messageIDs[firstPointer]; secondCandidate = second.messageIDs[secondPointer]; } return { messageIDs: mergedMessageIDs, startReached: first.startReached && second.startReached, }; } type MessagePreviewPart = { +text: string, // unread has highest contrast, followed by primary, followed by secondary +style: 'unread' | 'primary' | 'secondary', }; export type MessagePreviewResult = { +message: MessagePreviewPart, +username: ?MessagePreviewPart, }; function useMessagePreview( originalMessageInfo: ?MessageInfo, - threadInfo: ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, markdownRules: ParserRules, ): ?MessagePreviewResult { let messageInfo; if ( originalMessageInfo && originalMessageInfo.type === messageTypes.SIDEBAR_SOURCE ) { messageInfo = originalMessageInfo.sourceMessage; } else { messageInfo = originalMessageInfo; } const { parentThreadID } = threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const hasUsername = threadIsGroupChat(threadInfo) || threadInfo.name !== '' || messageInfo?.creator.isViewer; const shouldDisplayUser = messageInfo?.type === messageTypes.TEXT && hasUsername; const stringForUser = useStringForUser( shouldDisplayUser ? messageInfo?.creator : null, ); const { unread } = threadInfo.currentUser; const username = React.useMemo(() => { if (!shouldDisplayUser) { return null; } invariant( stringForUser, 'useStringForUser should only return falsey if pass null or undefined', ); return { text: stringForUser, style: unread ? 'unread' : 'secondary', }; }, [shouldDisplayUser, stringForUser, unread]); const messageTitleEntityText = React.useMemo(() => { if (!messageInfo) { return messageInfo; } return getMessageTitle( messageInfo, threadInfo, parentThreadInfo, markdownRules, ); }, [messageInfo, threadInfo, parentThreadInfo, markdownRules]); const threadID = threadInfo.id; const entityTextToStringParams = React.useMemo( () => ({ threadID, }), [threadID], ); const messageTitle = useEntityTextAsString( messageTitleEntityText, entityTextToStringParams, ); const isTextMessage = messageInfo?.type === messageTypes.TEXT; const message = React.useMemo(() => { if (messageTitle === null || messageTitle === undefined) { return messageTitle; } let style; if (unread) { style = 'unread'; } else if (isTextMessage) { style = 'primary'; } else { style = 'secondary'; } return { text: messageTitle, style }; }, [messageTitle, unread, isTextMessage]); return React.useMemo(() => { if (!message) { return message; } return { message, username }; }, [message, username]); } function useMessageCreationSideEffectsFunc( messageType: $PropertyType, ): CreationSideEffectsFunc { const messageSpec = messageSpecs[messageType]; invariant(messageSpec, `we're not aware of messageType ${messageType}`); invariant( messageSpec.useCreationSideEffectsFunc, `no useCreationSideEffectsFunc in message spec for ${messageType}`, ); return messageSpec.useCreationSideEffectsFunc(); } function getPinnedContentFromMessage(targetMessage: RawMessageInfo): string { let pinnedContent; if ( targetMessage.type === messageTypes.IMAGES || targetMessage.type === messageTypes.MULTIMEDIA ) { pinnedContent = contentStringForMediaArray(targetMessage.media); } else { pinnedContent = 'a message'; } return pinnedContent; } function modifyItemForResultScreen( item: ChatMessageInfoItem, ): ChatMessageInfoItem { if (item.messageInfoType === 'composable') { return { ...item, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; } return item; } function constructChangeRoleEntityText( affectedUsers: EntityText | string, roleName: ?string, ): EntityText { if (!roleName) { return ET`assigned ${affectedUsers} a new role`; } return ET`assigned ${affectedUsers} the "${roleName}" role`; } function useNextLocalID(): string { const nextLocalID = useSelector(state => state.nextLocalID); return `${localIDPrefix}${nextLocalID}`; } function isInvalidSidebarSource( message: RawMessageInfo | MessageInfo, ): boolean %checks { return ( (message.type === messageTypes.REACTION || message.type === messageTypes.EDIT_MESSAGE || message.type === messageTypes.SIDEBAR_SOURCE || message.type === messageTypes.TOGGLE_PIN) && !messageSpecs[message.type].canBeSidebarSource ); } // Prefer checking isInvalidPinSourceForThread below. This function doesn't // check whether the user is attempting to pin a SIDEBAR_SOURCE in the context // of its parent thread, so it's not suitable for permission checks. We only // use it in the message-fetchers.js code where we don't have access to the // RawThreadInfo and don't need to do permission checks. function isInvalidPinSource( messageInfo: RawMessageInfo | MessageInfo, ): boolean { return !messageSpecs[messageInfo.type].canBePinned; } function isInvalidPinSourceForThread( messageInfo: RawMessageInfo | MessageInfo, threadInfo: | RawThreadInfo | ThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, ): boolean { const isValidPinSource = !isInvalidPinSource(messageInfo); const isFirstMessageInSidebar = threadInfo.sourceMessageID === messageInfo.id; return !isValidPinSource || isFirstMessageInSidebar; } function isUnableToBeRenderedIndependently( message: RawMessageInfo | MessageInfo, ): boolean { return !messageSpecs[message.type].canBeRenderedIndependently; } export { localIDPrefix, messageKey, messageID, robotextForMessageInfo, createMessageInfo, sortMessageInfoList, sortMessageIDs, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, usersInMessageInfos, combineTruncationStatuses, shimUnsupportedRawMessageInfos, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageQuote, createMessageReply, getMostRecentNonLocalMessageID, getOldestNonLocalMessageID, getMessageTitle, mergeThreadMessageInfos, useMessagePreview, useMessageCreationSideEffectsFunc, getPinnedContentFromMessage, modifyItemForResultScreen, constructChangeRoleEntityText, useNextLocalID, isInvalidSidebarSource, isInvalidPinSource, isInvalidPinSourceForThread, isUnableToBeRenderedIndependently, }; diff --git a/lib/shared/messages/message-spec.js b/lib/shared/messages/message-spec.js index 00c989d36..18d4c2de1 100644 --- a/lib/shared/messages/message-spec.js +++ b/lib/shared/messages/message-spec.js @@ -1,122 +1,123 @@ // @flow import type { TType } from 'tcomb'; import type { PlatformDetails } from '../../types/device-types.js'; import type { Media } from '../../types/media-types.js'; import type { MessageInfo, ClientDBMessageInfo, RawComposableMessageInfo, RawMessageInfo, RawRobotextMessageInfo, } from '../../types/message-types.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; +import type { MinimallyEncodedThreadInfo } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo, UserInfo } from '../../types/user-types.js'; import type { EntityText } from '../../utils/entity-text.js'; import { type ParserRules } from '../markdown.js'; export type MessageTitleParam = { +messageInfo: Info, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +markdownRules: ParserRules, }; export type RawMessageInfoFromServerDBRowParams = { +localID: ?string, +media?: $ReadOnlyArray, +derivedMessages?: $ReadOnlyMap< string, RawComposableMessageInfo | RawRobotextMessageInfo, >, }; export type CreateMessageInfoParams = { +threadInfos: { +[id: string]: ThreadInfo }, +createMessageInfoFromRaw: (rawInfo: RawMessageInfo) => ?MessageInfo, +createRelativeUserInfos: ( userIDs: $ReadOnlyArray, ) => RelativeUserInfo[], }; export type RobotextParams = { - +threadInfo: ?ThreadInfo, - +parentThreadInfo: ?ThreadInfo, + +threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, + +parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, }; export type NotificationTextsParams = { +notifTargetUserInfo: UserInfo, +parentThreadInfo: ?ThreadInfo, }; export type GeneratesNotifsParams = { +notifTargetUserID: string, +userNotMemberOfSubthreads: Set, +fetchMessageInfoByID: (messageID: string) => Promise, }; export const pushTypes = Object.freeze({ NOTIF: 'notif', RESCIND: 'rescind', }); export type PushType = $Values; export type CreationSideEffectsFunc = ( messageInfo: RawInfo, - threadInfo: ThreadInfo, - parentThreadInfo: ?ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, ) => Promise; export type MessageSpec = { +messageContentForServerDB?: (data: Data | RawInfo) => string, +messageContentForClientDB?: (data: RawInfo) => string, +messageTitle?: (param: MessageTitleParam) => EntityText, +rawMessageInfoFromServerDBRow?: ( row: Object, params: RawMessageInfoFromServerDBRowParams, ) => ?RawInfo, +rawMessageInfoFromClientDB: ( clientDBMessageInfo: ClientDBMessageInfo, ) => RawInfo, +createMessageInfo: ( rawMessageInfo: RawInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ) => ?Info, +rawMessageInfoFromMessageData?: (messageData: Data, id: ?string) => RawInfo, +robotext?: (messageInfo: Info, params: RobotextParams) => EntityText, +shimUnsupportedMessageInfo?: ( rawMessageInfo: RawInfo, platformDetails: ?PlatformDetails, ) => RawInfo | RawUnsupportedMessageInfo, +unshimMessageInfo?: ( unwrapped: RawInfo, messageInfo: RawMessageInfo, ) => ?RawMessageInfo, +notificationTexts?: ( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ) => Promise, +notificationCollapseKey?: ( rawMessageInfo: RawInfo, messageData: Data, ) => ?string, +generatesNotifs?: ( rawMessageInfo: RawInfo, messageData: Data, params: GeneratesNotifsParams, ) => Promise, +userIDs?: (rawMessageInfo: RawInfo) => $ReadOnlyArray, +startsThread?: boolean, +threadIDs?: (rawMessageInfo: RawInfo) => $ReadOnlyArray, +includedInRepliesCount?: boolean, +canBeSidebarSource: boolean, +canBePinned: boolean, +canBeRenderedIndependently?: boolean, +parseDerivedMessages?: (row: Object, requiredIDs: Set) => void, +useCreationSideEffectsFunc?: () => CreationSideEffectsFunc, +validator: TType, }; diff --git a/lib/shared/messages/text-message-spec.js b/lib/shared/messages/text-message-spec.js index addab039e..20318af85 100644 --- a/lib/shared/messages/text-message-spec.js +++ b/lib/shared/messages/text-message-spec.js @@ -1,322 +1,323 @@ // @flow import invariant from 'invariant'; import * as SimpleMarkdown from 'simple-markdown'; import { pushTypes, type MessageSpec, type RawMessageInfoFromServerDBRowParams, type NotificationTextsParams, } from './message-spec.js'; import { assertSingleMessageInfo, joinResult } from './utils.js'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from '../../actions/thread-actions.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import { type RawTextMessageInfo, type TextMessageData, type TextMessageInfo, rawTextMessageInfoValidator, } from '../../types/messages/text.js'; +import type { MinimallyEncodedThreadInfo } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { NotifTexts } from '../../types/notif-types.js'; import { threadTypes } from '../../types/thread-types-enum.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { useDispatchActionPromise } from '../../utils/action-utils.js'; import { ET } from '../../utils/entity-text.js'; import { type ASTNode, type SingleASTNode, stripSpoilersFromNotifications, stripSpoilersFromMarkdownAST, } from '../markdown.js'; import { isUserMentioned, renderChatMentionsWithAltText, } from '../mention-utils.js'; import { notifTextsForSidebarCreation } from '../notif-utils.js'; import { threadIsGroupChat, extractNewMentionedParentMembers, } from '../thread-utils.js'; /** * most of the markdown leaves contain `content` field * (it is an array or a string) apart from lists, * which have `items` field (that holds an array) */ const rawTextFromMarkdownAST = (node: ASTNode): string => { if (Array.isArray(node)) { return node.map(rawTextFromMarkdownAST).join(''); } const { content, items } = node; if (content && typeof content === 'string') { return content; } else if (items) { return rawTextFromMarkdownAST(items); } else if (content) { return rawTextFromMarkdownAST(content); } return ''; }; const getFirstNonQuotedRawLine = ( nodes: $ReadOnlyArray, ): string => { let result = 'message'; for (const node of nodes) { if (node.type === 'blockQuote') { result = 'quoted message'; } else { const rawText = rawTextFromMarkdownAST(node); if (!rawText || !rawText.replace(/\s/g, '')) { // handles the case of an empty(or containing only white spaces) // new line that usually occurs between a quote and the rest // of the message(we don't want it as a title, thus continue) continue; } return rawText; } } return result; }; export const textMessageSpec: MessageSpec< TextMessageData, RawTextMessageInfo, TextMessageInfo, > = Object.freeze({ messageContentForServerDB( data: TextMessageData | RawTextMessageInfo, ): string { return data.text; }, messageContentForClientDB(data: RawTextMessageInfo): string { return this.messageContentForServerDB(data); }, messageTitle({ messageInfo, markdownRules }) { const { text } = messageInfo; const parser = SimpleMarkdown.parserFor(markdownRules); const ast = stripSpoilersFromMarkdownAST( parser(text, { disableAutoBlockNewlines: true }), ); return ET`${getFirstNonQuotedRawLine(ast).trim()}`; }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawTextMessageInfo { let rawTextMessageInfo: RawTextMessageInfo = { type: messageTypes.TEXT, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), text: row.content, }; if (params.localID) { rawTextMessageInfo = { ...rawTextMessageInfo, localID: params.localID }; } return rawTextMessageInfo; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawTextMessageInfo { let rawTextMessageInfo: RawTextMessageInfo = { type: messageTypes.TEXT, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, text: clientDBMessageInfo.content ?? '', }; if (clientDBMessageInfo.local_id) { rawTextMessageInfo = { ...rawTextMessageInfo, localID: clientDBMessageInfo.local_id, }; } if (clientDBMessageInfo.id !== clientDBMessageInfo.local_id) { rawTextMessageInfo = { ...rawTextMessageInfo, id: clientDBMessageInfo.id, }; } return rawTextMessageInfo; }, createMessageInfo( rawMessageInfo: RawTextMessageInfo, creator: RelativeUserInfo, ): TextMessageInfo { let messageInfo: TextMessageInfo = { type: messageTypes.TEXT, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, text: rawMessageInfo.text, }; if (rawMessageInfo.id) { messageInfo = { ...messageInfo, id: rawMessageInfo.id }; } if (rawMessageInfo.localID) { messageInfo = { ...messageInfo, localID: rawMessageInfo.localID }; } return messageInfo; }, rawMessageInfoFromMessageData( messageData: TextMessageData, id: ?string, ): RawTextMessageInfo { const { sidebarCreation, ...rest } = messageData; if (id) { return { ...rest, id }; } else { return { ...rest }; } }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): Promise { // We special-case sidebarCreations. Usually we don't send any notifs in // that case to avoid a double-notif, but we need to update the original // notif if somebody was @-tagged in this message if ( messageInfos.length === 3 && messageInfos[2].type === messageTypes.SIDEBAR_SOURCE && messageInfos[1].type === messageTypes.CREATE_SIDEBAR ) { const sidebarSourceMessageInfo = messageInfos[2]; const createSidebarMessageInfo = messageInfos[1]; const sourceMessage = messageInfos[2].sourceMessage; const { username } = params.notifTargetUserInfo; if (!username) { // If we couldn't fetch the username for some reason, we won't be able // to extract @-mentions anyways, so we'll give up on updating the notif return null; } if ( sourceMessage.type === messageTypes.TEXT && isUserMentioned(username, sourceMessage.text) ) { // If the notif target was already mentioned in the source message, // there's no need to update the notif return null; } const messageInfo = messageInfos[0]; invariant( messageInfo.type === messageTypes.TEXT, 'messageInfo should be messageTypes.TEXT!', ); if (!isUserMentioned(username, messageInfo.text)) { // We only need to update the notif if the notif target is mentioned return null; } return notifTextsForSidebarCreation({ createSidebarMessageInfo, sidebarSourceMessageInfo, firstSidebarMessageInfo: messageInfo, threadInfo, params, }); } const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.TEXT, 'messageInfo should be messageTypes.TEXT!', ); const transformedNotificationText = stripSpoilersFromNotifications( renderChatMentionsWithAltText(messageInfo.text), ); if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { const thread = ET.thread({ display: 'uiName', threadInfo }); return { merged: ET`${thread}: ${transformedNotificationText}`, body: transformedNotificationText, title: threadInfo.uiName, }; } else { const creator = ET.user({ userInfo: messageInfo.creator }); const thread = ET.thread({ display: 'shortName', threadInfo }); return { merged: ET`${creator} to ${thread}: ${transformedNotificationText}`, body: transformedNotificationText, title: threadInfo.uiName, prefix: ET`${creator}:`, }; } }, notificationCollapseKey( rawMessageInfo: RawTextMessageInfo, messageData: TextMessageData, ): ?string { if (!messageData.sidebarCreation) { return null; } return joinResult(messageTypes.CREATE_SIDEBAR, rawMessageInfo.threadID); }, generatesNotifs: async () => pushTypes.NOTIF, includedInRepliesCount: true, useCreationSideEffectsFunc: () => { const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useChangeThreadSettings(); return async ( messageInfo: RawTextMessageInfo, - threadInfo: ThreadInfo, - parentThreadInfo: ?ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, ) => { if (threadInfo.type !== threadTypes.SIDEBAR) { return; } invariant(parentThreadInfo, 'all sidebars should have a parent thread'); const mentionedNewMembers = extractNewMentionedParentMembers( messageInfo.text, threadInfo, parentThreadInfo, ); if (mentionedNewMembers.length === 0) { return; } const newMemberIDs = mentionedNewMembers.map(({ id }) => id); const addMembersPromise = callChangeThreadSettings({ threadID: threadInfo.id, changes: { newMemberIDs }, }); dispatchActionPromise(changeThreadSettingsActionTypes, addMembersPromise); await addMembersPromise; }; }, canBeSidebarSource: true, canBePinned: true, validator: rawTextMessageInfoValidator, }); diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 7b3f70e79..3000257e4 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1981 +1,1996 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omitBy from 'lodash/fp/omitBy.js'; import * as React from 'react'; 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 threadWatcher from './thread-watcher.js'; import { fetchMostRecentMessagesActionTypes, useFetchMostRecentMessages, } from '../actions/message-actions.js'; import type { RemoveUsersFromThreadInput } from '../actions/thread-actions'; import { changeThreadMemberRolesActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, } from '../actions/thread-actions.js'; import { searchUsers as searchUserCall } from '../actions/user-actions.js'; import ashoat from '../facts/ashoat.js'; import genesis from '../facts/genesis.js'; import { useLoggedInUserInfo } from '../hooks/account-hooks.js'; import { hasPermission, permissionsToBitmaskHex, threadPermissionsFromBitmaskHex, } from '../permissions/minimally-encoded-thread-permissions.js'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions.js'; import type { ChatThreadItem, ChatMessageInfoItem, } from '../selectors/chat-selectors.js'; import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { threadInfoSelector, pendingToRealizedThreadIDsSelector, threadInfosSelectorForThreadType, } from '../selectors/thread-selectors.js'; import { getRelativeMemberInfos, usersWithPersonalThreadSelector, } from '../selectors/user-selectors.js'; import { getUserAvatarForThread } from '../shared/avatar-utils.js'; import type { CalendarQuery } from '../types/entry-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type RobotextMessageInfo, type ComposableMessageInfo, } from '../types/message-types.js'; import type { MinimallyEncodedMemberInfo, MinimallyEncodedRawThreadInfo, MinimallyEncodedRelativeMemberInfo, MinimallyEncodedRoleInfo, MinimallyEncodedThreadCurrentUserInfo, MinimallyEncodedThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { decodeMinimallyEncodedRoleInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissionPropagationPrefixes, threadPermissions, configurableCommunityPermissions, type ThreadPermission, type ThreadPermissionsInfo, type UserSurfacedPermission, } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes, threadTypeIsCommunityRoot, assertThreadType, } from '../types/thread-types-enum.js'; import { type RawThreadInfo, type ThreadInfo, type MemberInfo, type ServerThreadInfo, type RelativeMemberInfo, type ThreadCurrentUserInfo, type RoleInfo, type ServerMemberInfo, type ClientNewThreadRequest, type NewThreadResult, type ChangeThreadSettingsPayload, type UserProfileThreadInfo, } 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 { useDispatchActionPromise, useServerCall, } from '../utils/action-utils.js'; import type { DispatchActionPromise } from '../utils/action-utils.js'; import type { GetENSNames } from '../utils/ens-helpers.js'; import { ET, entityTextToRawString, getEntityTextAsString, type ThreadEntity, type UserEntity, } from '../utils/entity-text.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 | RawThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo ), 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 viewerIsMember( threadInfo: ?( | ThreadInfo | RawThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo ), ): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome( threadInfo: ?( | ThreadInfo | RawThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo ), ): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList( threadInfo: ?( | ThreadInfo | RawThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo ), ): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function threadIsTopLevel( threadInfo: ?( | ThreadInfo | RawThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo ), ): boolean { return threadInChatList(threadInfo) && threadIsChannel(threadInfo); } function threadIsChannel( threadInfo: ?( | ThreadInfo | RawThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo ), ): boolean { return !!(threadInfo && threadInfo.type !== threadTypes.SIDEBAR); } function threadIsSidebar( threadInfo: ?( | ThreadInfo | RawThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo ), ): boolean { return threadInfo?.type === threadTypes.SIDEBAR; } function threadInBackgroundChatList( threadInfo: ?( | ThreadInfo | RawThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo ), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?( | ThreadInfo | RawThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo ), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?( | ThreadInfo | RawThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo ), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?( | ThreadInfo | RawThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo ), 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< | MemberInfo | RelativeMemberInfo | MinimallyEncodedMemberInfo | MinimallyEncodedRelativeMemberInfo, >, ): $ReadOnlyArray { return memberInfos .filter(memberInfo => memberInfo.role) .map(memberInfo => memberInfo.id); } function threadOtherMembers< T: | MemberInfo | RelativeMemberInfo | MinimallyEncodedMemberInfo | MinimallyEncodedRelativeMemberInfo, >(memberInfos: $ReadOnlyArray, viewerID: ?string): $ReadOnlyArray { return memberInfos.filter( memberInfo => memberInfo.role && memberInfo.id !== viewerID, ); } function threadMembersWithoutAddedAshoat< T: | ThreadInfo | RawThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, >(threadInfo: T): $PropertyType { if (threadInfo.community !== genesis.id) { return threadInfo.members; } return threadInfo.members.filter( member => member.id !== ashoat.id || member.role, ); } function threadIsGroupChat( threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, ): boolean { return threadInfo.members.length > 2; } function threadOrParentThreadIsGroupChat( threadInfo: | RawThreadInfo | ThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, ) { return threadMembersWithoutAddedAshoat(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: | ThreadInfo | RawThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, 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, + +parentThreadInfo?: ?ThreadInfo | ?MinimallyEncodedThreadInfo, +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 = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role = { id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }; const rawThreadInfo = { id: threadID, type: threadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID(parentThreadInfo, threadType), community: getCommunity(parentThreadInfo), members: members.map(member => ({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, })), roles: { [role.id]: role, }, currentUser: { role: role.id, permissions: membershipPermissions, subscription: { pushNotifs: false, home: false, }, unread: false, }, repliesCount: 0, sourceMessageID, pinnedCount: 0, }; const userInfos = {}; 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< RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo, >, ): 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 | MinimallyEncodedThreadInfo, ): 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 | MinimallyEncodedThreadInfo, parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, ): 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, + +parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +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, }; async function createPendingSidebar( input: CreatePendingSidebarInput, ): Promise { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, getENSNames, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = await getEntityTextAsString( messageTitleEntityText, getENSNames, { 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 CreateRealThreadParameters = { +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +dispatchActionPromise: DispatchActionPromise, +createNewThread: ClientNewThreadRequest => Promise, +sourceMessageID: ?string, +viewerID: ?string, +handleError?: () => mixed, +calendarQuery: CalendarQuery, }; async function createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise, createNewThread, sourceMessageID, viewerID, calendarQuery, }: CreateRealThreadParameters): Promise { if (!threadIsPending(threadInfo.id)) { return threadInfo.id; } const otherMemberIDs = threadOtherMembers(threadInfo.members, viewerID).map( member => member.id, ); let resultPromise; if (threadInfo.type !== threadTypes.SIDEBAR) { invariant( otherMemberIDs.length > 0, 'otherMemberIDs should not be empty for threads', ); resultPromise = createNewThread({ type: pendingThreadType(otherMemberIDs.length), initialMemberIDs: otherMemberIDs, color: threadInfo.color, calendarQuery, }); } else { invariant( sourceMessageID, 'sourceMessageID should be set when creating a sidebar', ); resultPromise = createNewThread({ type: threadTypes.SIDEBAR, initialMemberIDs: otherMemberIDs, color: threadInfo.color, sourceMessageID, parentThreadID: threadInfo.parentThreadID, name: threadInfo.name, calendarQuery, }); } dispatchActionPromise(newThreadActionTypes, resultPromise); const { newThreadID } = await resultPromise; return newThreadID; } type RawThreadInfoOptions = { +filterThreadEditAvatarPermission?: boolean, +excludePinInfo?: boolean, +filterManageInviteLinksPermission?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?RawThreadInfo { const filterThreadEditAvatarPermission = options?.filterThreadEditAvatarPermission; const excludePinInfo = options?.excludePinInfo; const filterManageInviteLinksPermission = options?.filterManageInviteLinksPermission; 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)), ); 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); 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: rolesWithFilteredThreadPermissions, 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) { return { ...rawThreadInfo, pinnedCount: serverThreadInfo.pinnedCount, }; } return rawThreadInfo; } function threadUIName( threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, ): string | ThreadEntity { if (threadInfo.name) { return firstLine(threadInfo.name); } let threadMembers: | $ReadOnlyArray | $ReadOnlyArray; let memberEntities: $ReadOnlyArray; // Branching below is to appease flow if (threadInfo.minimallyEncoded) { threadMembers = threadInfo.members.filter(memberInfo => memberInfo.role); memberEntities = threadMembers.map(member => ET.user({ userInfo: member })); } else { threadMembers = threadInfo.members.filter(memberInfo => memberInfo.role); memberEntities = 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 = { 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 getCurrentUser( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } return { ...threadInfo.currentUser, permissions: { ...threadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function getMinimallyEncodedCurrentUser( threadInfo: MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, viewerID: ?string, userInfos: UserInfos, ): MinimallyEncodedThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } const decodedPermissions = threadPermissionsFromBitmaskHex( threadInfo.currentUser.permissions, ); const updatedPermissions = { ...decodedPermissions, ...disabledPermissions, }; const encodedUpdatedPermissions = permissionsToBitmaskHex(updatedPermissions); return { ...threadInfo.currentUser, permissions: encodedUpdatedPermissions, }; } function threadIsWithBlockedUserOnly( threadInfo: | RawThreadInfo | ThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, 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: | RawThreadInfo | ThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( threadInfo: | RawThreadInfo | ThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, 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.', }; function memberIsAdmin( memberInfo: | RelativeMemberInfo | MemberInfo | MinimallyEncodedMemberInfo | MinimallyEncodedRelativeMemberInfo, threadInfo: | ThreadInfo | RawThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, ): boolean { return !!( memberInfo.role && roleIsAdminRole(threadInfo.roles[memberInfo.role]) ); } // Since we don't have access to all of the ancestor ThreadInfos, we approximate // "parent admin" as anybody with CHANGE_ROLE permissions. function memberHasAdminPowers( memberInfo: | RelativeMemberInfo | MemberInfo | ServerMemberInfo | MinimallyEncodedMemberInfo | MinimallyEncodedRelativeMemberInfo, ): boolean { if (memberInfo.minimallyEncoded) { return hasPermission(memberInfo.permissions, threadPermissions.CHANGE_ROLE); } return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsAdminRole( roleInfo: ?RoleInfo | ?MinimallyEncodedRoleInfo, ): boolean { return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } function threadHasAdminRole( threadInfo: ?( | RawThreadInfo | ThreadInfo | ServerThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo ), ): boolean { if (!threadInfo) { return false; } return !!_find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: | RawThreadInfo | ThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, ) { return ( threadMembersWithoutAddedAshoat(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'; } } function useWatchThread(threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo) { const dispatchActionPromise = useDispatchActionPromise(); const callFetchMostRecentMessages = useFetchMostRecentMessages(); const threadID = threadInfo?.id; const threadNotInChatList = !threadInChatList(threadInfo); React.useEffect(() => { if (threadID && threadNotInChatList) { threadWatcher.watchID(threadID); dispatchActionPromise( fetchMostRecentMessagesActionTypes, callFetchMostRecentMessages({ threadID }), ); } return () => { if (threadID && threadNotInChatList) { threadWatcher.removeID(threadID); } }; }, [ callFetchMostRecentMessages, dispatchActionPromise, threadNotInChatList, threadID, ]); } type ExistingThreadInfoFinderParams = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, }; type ExistingThreadInfoFinder = ( params: ExistingThreadInfoFinderParams, -) => ?ThreadInfo; +) => ?ThreadInfo | ?MinimallyEncodedThreadInfo; +// TODO (atul): Parameterize function once `createPendingThread` is updated. function useExistingThreadInfoFinder( - baseThreadInfo: ?ThreadInfo, + baseThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, ): 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 => { + ( + params: ExistingThreadInfoFinderParams, + ): ?ThreadInfo | ?MinimallyEncodedThreadInfo => { 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), - }; + + if (updatedThread.minimallyEncoded) { + return { + ...updatedThread, + currentUser: getMinimallyEncodedCurrentUser( + updatedThread, + viewerID, + userInfos, + ), + }; + } else { + 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: | ServerThreadInfo | RawThreadInfo | ThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, memberID: string, permission: ThreadPermission, ): boolean { for (const member of threadInfo.members) { if (member.id !== memberID) { continue; } if (member.minimallyEncoded) { return hasPermission(member.permissions, permission); } return permissionLookup(member.permissions, permission); } return false; } function useCanCreateSidebarFromMessage( threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, messageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const messageCreatorUserInfo = useSelector( state => state.userStore.userInfos[messageInfo.creator.id], ); if ( !messageInfo.id || threadInfo.sourceMessageID === messageInfo.id || isInvalidSidebarSource(messageInfo) ) { return false; } const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; const creatorRelationshipHasBlock = messageCreatorRelationship && relationshipBlockedInEitherDirection(messageCreatorRelationship); const hasCreateSidebarsPermission = threadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); return hasCreateSidebarsPermission && !creatorRelationshipHasBlock; } function useSidebarExistsOrCanBeCreated( threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, messageItem: ChatMessageInfoItem, ): boolean { const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( threadInfo, messageItem.messageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } function checkIfDefaultMembersAreVoiced( threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, ): boolean { const defaultRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = threadInfo.roles[defaultRoleID]; const defaultRolePermissions = defaultRole.minimallyEncoded ? decodeMinimallyEncodedRoleInfo(defaultRole).permissions : defaultRole.permissions; return !!defaultRolePermissions[threadPermissions.VOICED]; } const draftKeySuffix = '/message_composer'; function draftKeyFromThreadID(threadID: string): string { return `${threadID}${draftKeySuffix}`; } function getContainingThreadID( parentThreadInfo: | ?ServerThreadInfo | RawThreadInfo | ThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, threadType: ThreadType, ): ?string { if (!parentThreadInfo) { return null; } if (threadType === threadTypes.SIDEBAR) { return parentThreadInfo.id; } if (!parentThreadInfo.containingThreadID) { return parentThreadInfo.id; } return parentThreadInfo.containingThreadID; } function getCommunity( parentThreadInfo: | ?ServerThreadInfo | RawThreadInfo | ThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, ): ?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 = [...privateThreads, ...personalThreads, ...otherThreads]; if (loggedInUserInfo) { chatItems.push( ...usersSearchResults.map(user => createPendingThreadItem(loggedInUserInfo, user), ), ); } return chatItems; } type ThreadListSearchResult = { +threadSearchResults: $ReadOnlySet, +usersSearchResults: $ReadOnlyArray, }; function useThreadListSearch( searchText: string, viewerID: ?string, ): ThreadListSearchResult { const callSearchUsers = useServerCall(searchUserCall); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); const searchUsers = React.useCallback( async (usernamePrefix: string) => { if (usernamePrefix.length === 0) { return []; } const { userInfos } = await callSearchUsers(usernamePrefix); return userInfos.filter( info => !usersWithPersonalThread.has(info.id) && info.id !== viewerID, ); }, [callSearchUsers, usersWithPersonalThread, viewerID], ); const [threadSearchResults, setThreadSearchResults] = React.useState( new Set(), ); const [usersSearchResults, setUsersSearchResults] = React.useState([]); const threadSearchIndex = useGlobalThreadSearchIndex(); React.useEffect(() => { (async () => { const results = threadSearchIndex.getSearchResults(searchText); setThreadSearchResults(new Set(results)); const usersResults = await searchUsers(searchText); setUsersSearchResults(usersResults); })(); }, [searchText, threadSearchIndex, searchUsers]); return { threadSearchResults, usersSearchResults }; } function removeMemberFromThread( threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - memberInfo: RelativeMemberInfo, + memberInfo: RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo, dispatchActionPromise: DispatchActionPromise, removeUserFromThreadServerCall: ( input: RemoveUsersFromThreadInput, ) => Promise, ) { const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`; dispatchActionPromise( removeUsersFromThreadActionTypes, removeUserFromThreadServerCall({ threadID: threadInfo.id, memberIDs: [memberInfo.id], }), { customKeyName }, ); } function switchMemberAdminRoleInThread( threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, memberInfo: RelativeMemberInfo, isCurrentlyAdmin: boolean, dispatchActionPromise: DispatchActionPromise, changeUserRoleServerCall: ( threadID: string, memberIDs: $ReadOnlyArray, newRole: string, ) => Promise, ) { let newRole = null; for (const roleID in threadInfo.roles) { const role = threadInfo.roles[roleID]; if (isCurrentlyAdmin && role.isDefault) { newRole = role.id; break; } else if (!isCurrentlyAdmin && roleIsAdminRole(role)) { newRole = role.id; break; } } invariant(newRole !== null, 'Could not find new role'); const customKeyName = `${changeThreadMemberRolesActionTypes.started}:${memberInfo.id}`; dispatchActionPromise( changeThreadMemberRolesActionTypes, changeUserRoleServerCall(threadInfo.id, [memberInfo.id], newRole), { customKeyName }, ); } function getAvailableThreadMemberActions( memberInfo: RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo, threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, canEdit: ?boolean = true, ): $ReadOnlyArray<'change_role' | 'remove_user'> { const role = memberInfo.role; if (!canEdit || !role) { return []; } const canRemoveMembers = threadHasPermission( threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = threadHasPermission( threadInfo, threadPermissions.CHANGE_ROLE, ); const result = []; if (canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo)) { result.push('change_role'); } if ( canRemoveMembers && !memberInfo.isViewer && (canChangeRoles || threadInfo.roles[role]?.isDefault) ) { result.push('remove_user'); } return result; } function patchThreadInfoToIncludeMentionedMembersOfParent( - threadInfo: ThreadInfo, - parentThreadInfo: ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, messageText: string, viewerID: string, -): ThreadInfo { +): ThreadInfo | MinimallyEncodedThreadInfo { const members: UserIDAndUsername[] = threadInfo.members .map(({ id, username }) => (username ? { id, username } : 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 | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, communityID: string, ): boolean { return threadInfo.community === communityID || threadInfo.id === communityID; } type RoleAndMemberCount = { [roleName: string]: number, }; function useRoleMemberCountsForCommunity( threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, ): RoleAndMemberCount { return React.useMemo(() => { const roleIDsToNames = {}; 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]); } 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 = {}; Object.keys(threadInfo.roles).forEach(roleID => { const roleName = threadInfo.roles[roleID].name; const rolePermissions = Object.keys(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]); } function communityOrThreadNoun( threadInfo: | RawThreadInfo | ThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, ): string { return threadTypeIsCommunityRoot(threadInfo.type) ? 'community' : threadNoun(threadInfo.type, threadInfo.parentThreadID); } function getThreadsToDeleteText( threadInfo: | RawThreadInfo | ThreadInfo | MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, ): 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, ]); } export { threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadIsChannel, threadIsSidebar, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadOtherMembers, threadIsGroupChat, threadIsPending, threadIsPendingSidebar, getSingleOtherUser, getPendingThreadID, parsePendingThreadID, createPendingThread, createUnresolvedPendingSidebar, extractNewMentionedParentMembers, createPendingSidebar, pendingThreadType, createRealThreadFromPendingThread, getCurrentUser, getMinimallyEncodedCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, threadUIName, threadInfoFromRawThreadInfo, threadTypeDescriptions, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadNoun, threadLabel, useWatchThread, useExistingThreadInfoFinder, getThreadTypeParentRequirement, threadMemberHasPermission, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, checkIfDefaultMembersAreVoiced, draftKeySuffix, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, useThreadListSearch, removeMemberFromThread, switchMemberAdminRoleInThread, getAvailableThreadMemberActions, threadMembersWithoutAddedAshoat, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, useRoleMemberCountsForCommunity, useRoleUserSurfacedPermissions, getThreadsToDeleteText, useUserProfileThreadInfo, }; diff --git a/lib/utils/drawer-utils.react.js b/lib/utils/drawer-utils.react.js index 8440cacdf..bda66115e 100644 --- a/lib/utils/drawer-utils.react.js +++ b/lib/utils/drawer-utils.react.js @@ -1,119 +1,127 @@ // @flow import { values } from './objects.js'; import { threadInFilterList, threadIsChannel } from '../shared/thread-utils.js'; import type { MinimallyEncodedRawThreadInfo, + MinimallyEncodedResolvedThreadInfo, MinimallyEncodedThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { communitySubthreads } from '../types/thread-types-enum.js'; import type { RawThreadInfo, ThreadInfo, ResolvedThreadInfo, } from '../types/thread-types.js'; export type CommunityDrawerItemData = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +itemChildren: $ReadOnlyArray>, +hasSubchannelsButton: boolean, +labelStyle: T, }; function createRecursiveDrawerItemsData( - childThreadInfosMap: { +[id: string]: $ReadOnlyArray }, - communities: $ReadOnlyArray, + childThreadInfosMap: { + +[id: string]: $ReadOnlyArray, + }, + communities: $ReadOnlyArray< + ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + >, labelStyles: $ReadOnlyArray, maxDepth: number, ): $ReadOnlyArray> { const result = communities.map(community => ({ threadInfo: community, itemChildren: [], labelStyle: labelStyles[0], hasSubchannelsButton: false, })); let queue = result.map(item => [item, 0]); for (let i = 0; i < queue.length; i++) { const [item, lvl] = queue[i]; const itemChildThreadInfos = childThreadInfosMap[item.threadInfo.id] ?? []; if (lvl < maxDepth) { item.itemChildren = itemChildThreadInfos .filter(childItem => communitySubthreads.includes(childItem.type)) .map(childItem => ({ threadInfo: childItem, itemChildren: [], labelStyle: labelStyles[Math.min(lvl + 1, labelStyles.length - 1)], hasSubchannelsButton: lvl + 1 === maxDepth && threadHasSubchannels(childItem, childThreadInfosMap), })); queue = queue.concat( item.itemChildren.map(childItem => [childItem, lvl + 1]), ); } } return result; } -function threadHasSubchannels( - threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +function threadHasSubchannels( + threadInfo: T, childThreadInfosMap: { - +[id: string]: - | $ReadOnlyArray - | $ReadOnlyArray, + +[id: string]: $ReadOnlyArray, }, ): boolean { if (!childThreadInfosMap[threadInfo.id]?.length) { return false; } return childThreadInfosMap[threadInfo.id].some(thread => threadIsChannel(thread), ); } -function appendSuffix( - chats: $ReadOnlyArray, -): ResolvedThreadInfo[] { +function appendSuffix< + T: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +>(chats: $ReadOnlyArray): T[] { const result = []; const names = new Map(); for (const chat of chats) { let name = chat.uiName; const numberOfOccurrences = names.get(name); names.set(name, (numberOfOccurrences ?? 0) + 1); if (numberOfOccurrences) { name = `${name} (${numberOfOccurrences.toString()})`; } - result.push({ ...chat, uiName: name }); + // Branching to appease `flow`. + if (chat.minimallyEncoded) { + result.push({ ...chat, uiName: name }); + } else { + result.push({ ...chat, uiName: name }); + } } return result; } function filterThreadIDsBelongingToCommunity( communityID: string, threadInfosObj: { +[id: string]: | ThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo | MinimallyEncodedRawThreadInfo, }, ): $ReadOnlySet { const threadInfos = values(threadInfosObj); const threadIDs = threadInfos .filter( thread => (thread.community === communityID || thread.id === communityID) && threadInFilterList(thread), ) .map(item => item.id); return new Set(threadIDs); } export { createRecursiveDrawerItemsData, appendSuffix, filterThreadIDsBelongingToCommunity, }; diff --git a/lib/utils/entity-helpers.js b/lib/utils/entity-helpers.js index 4fa733ced..20f1e69f4 100644 --- a/lib/utils/entity-helpers.js +++ b/lib/utils/entity-helpers.js @@ -1,128 +1,142 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ET, useENSNamesForEntityText, entityTextToRawString, } from './entity-text.js'; import type { UseENSNamesOptions } from '../hooks/ens-cache.js'; +import type { + MinimallyEncodedResolvedThreadInfo, + MinimallyEncodedThreadInfo, +} from '../types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo, ResolvedThreadInfo } from '../types/thread-types.js'; import { values } from '../utils/objects.js'; function useResolvedThreadInfos( - threadInfos: $ReadOnlyArray, + threadInfos: $ReadOnlyArray, options?: ?UseENSNamesOptions, -): $ReadOnlyArray { +): $ReadOnlyArray { const entityText = React.useMemo( () => threadInfos.map(threadInfo => threadInfo.uiName), [threadInfos], ); const withENSNames = useENSNamesForEntityText(entityText, options); invariant( withENSNames, 'useENSNamesForEntityText only returns falsey when passed falsey', ); return React.useMemo( () => threadInfos.map((threadInfo, i) => { if (typeof threadInfo.uiName === 'string') { // Flow wants return { ...threadInfo, uiName: threadInfo.uiName } // but that's wasteful and unneeded, so we any-cast here return (threadInfo: any); } const resolvedThreadEntity = withENSNames[i]; - return { - ...threadInfo, - uiName: entityTextToRawString([resolvedThreadEntity]), - }; + // Branching to appease `flow`. + if (threadInfo.minimallyEncoded) { + return { + ...threadInfo, + uiName: entityTextToRawString([resolvedThreadEntity]), + }; + } else { + return { + ...threadInfo, + uiName: entityTextToRawString([resolvedThreadEntity]), + }; + } }), [threadInfos, withENSNames], ); } function useResolvedOptionalThreadInfos( threadInfos: ?$ReadOnlyArray, ): ?$ReadOnlyArray { const entityText = React.useMemo(() => { if (!threadInfos) { return null; } return threadInfos.map(threadInfo => ET.thread({ display: 'uiName', threadInfo }), ); }, [threadInfos]); const withENSNames = useENSNamesForEntityText(entityText); return React.useMemo(() => { if (!threadInfos) { return threadInfos; } invariant( withENSNames, 'useENSNamesForEntityText only returns falsey when passed falsey', ); return threadInfos.map((threadInfo, i) => { if (typeof threadInfo.uiName === 'string') { // Flow wants return { ...threadInfo, uiName: threadInfo.uiName } // but that's wasteful and unneeded, so we any-cast here return (threadInfo: any); } const resolvedThreadEntity = withENSNames[i]; return { ...threadInfo, uiName: entityTextToRawString([resolvedThreadEntity]), }; }); }, [threadInfos, withENSNames]); } function useResolvedThreadInfosObj( threadInfosObj: { +[id: string]: ThreadInfo }, options?: ?UseENSNamesOptions, ): { +[id: string]: ResolvedThreadInfo } { const threadInfosArray = React.useMemo( () => values(threadInfosObj), [threadInfosObj], ); const resolvedThreadInfosArray = useResolvedThreadInfos( threadInfosArray, options, ); return React.useMemo(() => { const obj = {}; for (const resolvedThreadInfo of resolvedThreadInfosArray) { obj[resolvedThreadInfo.id] = resolvedThreadInfo; } return obj; }, [resolvedThreadInfosArray]); } -function useResolvedThreadInfo(threadInfo: ThreadInfo): ResolvedThreadInfo { +function useResolvedThreadInfo( + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +): ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo { const resolutionInput = React.useMemo(() => [threadInfo], [threadInfo]); const [resolvedThreadInfo] = useResolvedThreadInfos(resolutionInput); return resolvedThreadInfo; } function useResolvedOptionalThreadInfo( - threadInfo: ?ThreadInfo, -): ?ResolvedThreadInfo { + threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, +): ?ResolvedThreadInfo | ?MinimallyEncodedResolvedThreadInfo { const resolutionInput = React.useMemo( () => (threadInfo ? [threadInfo] : []), [threadInfo], ); const [resolvedThreadInfo] = useResolvedThreadInfos(resolutionInput); if (!threadInfo) { return threadInfo; } return resolvedThreadInfo; } export { useResolvedThreadInfos, useResolvedOptionalThreadInfos, useResolvedThreadInfosObj, useResolvedThreadInfo, useResolvedOptionalThreadInfo, }; diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js index f8d877135..920baf7f8 100644 --- a/native/calendar/entry.react.js +++ b/native/calendar/entry.react.js @@ -1,822 +1,823 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import _omit from 'lodash/fp/omit.js'; import * as React from 'react'; import { View, Text, TextInput as BaseTextInput, Platform, TouchableWithoutFeedback, LayoutAnimation, Keyboard, } from 'react-native'; import { useDispatch } from 'react-redux'; import shallowequal from 'shallowequal'; import tinycolor from 'tinycolor2'; import { createEntryActionTypes, useCreateEntry, saveEntryActionTypes, useSaveEntry, deleteEntryActionTypes, useDeleteEntry, concurrentModificationResetActionType, } from 'lib/actions/entry-actions.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import type { Shape } from 'lib/types/core.js'; import type { CreateEntryInfo, SaveEntryInfo, SaveEntryResult, SaveEntryPayload, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResult, CalendarQuery, } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; +import type { MinimallyEncodedResolvedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadInfo, type ResolvedThreadInfo, } from 'lib/types/thread-types.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils.js'; import { dateString } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { ServerError } from 'lib/utils/errors.js'; import sleep from 'lib/utils/sleep.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import type { EntryInfoWithHeight } from './calendar.react.js'; import LoadingIndicator from './loading-indicator.react.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import Button from '../components/button.react.js'; import SingleLine from '../components/single-line.react.js'; import TextInput from '../components/text-input.react.js'; import Markdown from '../markdown/markdown.react.js'; import { inlineMarkdownRules } from '../markdown/rules.react.js'; import { createIsForegroundSelector, nonThreadCalendarQuery, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { ThreadPickerModalRouteName } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { colors, useStyles } from '../themes/colors.js'; import type { LayoutEvent } from '../types/react-native.js'; import Alert from '../utils/alert.js'; import { waitForInteractions } from '../utils/timers.js'; function hueDistance(firstColor: string, secondColor: string): number { const firstHue = tinycolor(firstColor).toHsv().h; const secondHue = tinycolor(secondColor).toHsv().h; const distance = Math.abs(firstHue - secondHue); return distance > 180 ? 360 - distance : distance; } const omitEntryInfo = _omit(['entryInfo']); function dummyNodeForEntryHeightMeasurement( entryText: string, ): React.Element { const text = entryText === '' ? ' ' : entryText; return ( {text} ); } type SharedProps = { +navigation: TabNavigationProp<'Calendar'>, +entryInfo: EntryInfoWithHeight, +visible: boolean, +active: boolean, +makeActive: (entryKey: string, active: boolean) => void, +onEnterEditMode: (entryInfo: EntryInfoWithHeight) => void, +onConcludeEditMode: (entryInfo: EntryInfoWithHeight) => void, +onPressWhitespace: () => void, +entryRef: (entryKey: string, entry: ?InternalEntry) => void, }; type BaseProps = { ...SharedProps, +threadInfo: ThreadInfo, }; type Props = { ...SharedProps, - +threadInfo: ResolvedThreadInfo, + +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, // Redux state +calendarQuery: () => CalendarQuery, +online: boolean, +styles: typeof unboundStyles, // Nav state +threadPickerActive: boolean, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +createEntry: (info: CreateEntryInfo) => Promise, +saveEntry: (info: SaveEntryInfo) => Promise, +deleteEntry: (info: DeleteEntryInfo) => Promise, }; type State = { +editing: boolean, +text: string, +loadingStatus: LoadingStatus, +height: number, }; class InternalEntry extends React.Component { textInput: ?React.ElementRef; creating: boolean = false; needsUpdateAfterCreation: boolean = false; needsDeleteAfterCreation: boolean = false; nextSaveAttemptIndex: number = 0; mounted: boolean = false; deleted: boolean = false; currentlySaving: ?string; constructor(props: Props) { super(props); this.state = { editing: false, text: props.entryInfo.text, loadingStatus: 'inactive', height: props.entryInfo.textHeight, }; this.state = { ...this.state, editing: InternalEntry.isActive(props, this.state), }; } guardedSetState(input: Shape) { if (this.mounted) { this.setState(input); } } shouldComponentUpdate(nextProps: Props, nextState: State): boolean { return ( !shallowequal(nextState, this.state) || !shallowequal(omitEntryInfo(nextProps), omitEntryInfo(this.props)) || !_isEqual(nextProps.entryInfo)(this.props.entryInfo) ); } componentDidUpdate(prevProps: Props, prevState: State) { const wasActive = InternalEntry.isActive(prevProps, prevState); const isActive = InternalEntry.isActive(this.props, this.state); if ( !isActive && (this.props.entryInfo.text !== prevProps.entryInfo.text || this.props.entryInfo.textHeight !== prevProps.entryInfo.textHeight) && (this.props.entryInfo.text !== this.state.text || this.props.entryInfo.textHeight !== this.state.height) ) { this.guardedSetState({ text: this.props.entryInfo.text, height: this.props.entryInfo.textHeight, }); this.currentlySaving = null; } if ( !this.props.active && this.state.text === prevState.text && this.state.height !== prevState.height && this.state.height !== this.props.entryInfo.textHeight ) { const approxMeasuredHeight = Math.round(this.state.height * 1000) / 1000; const approxExpectedHeight = Math.round(this.props.entryInfo.textHeight * 1000) / 1000; console.log( `Entry height for ${entryKey(this.props.entryInfo)} was expected to ` + `be ${approxExpectedHeight} but is actually ` + `${approxMeasuredHeight}. This means Calendar's FlatList isn't ` + 'getting the right item height for some of its nodes, which is ' + 'guaranteed to cause glitchy behavior. Please investigate!!', ); } // Our parent will set the active prop to false if something else gets // pressed or if the Entry is scrolled out of view. In either of those cases // we should complete the edit process. if (!this.props.active && prevProps.active) { this.completeEdit(); } if (this.state.height !== prevState.height || isActive !== wasActive) { LayoutAnimation.easeInEaseOut(); } if ( this.props.online && !prevProps.online && this.state.loadingStatus === 'error' ) { this.save(); } if ( this.state.editing && prevState.editing && (this.state.text.trim() === '') !== (prevState.text.trim() === '') ) { LayoutAnimation.easeInEaseOut(); } } componentDidMount() { this.mounted = true; this.props.entryRef(entryKey(this.props.entryInfo), this); } componentWillUnmount() { this.mounted = false; this.props.entryRef(entryKey(this.props.entryInfo), null); this.props.onConcludeEditMode(this.props.entryInfo); } static isActive(props: Props, state: State): boolean { return ( props.active || state.editing || !props.entryInfo.id || state.loadingStatus !== 'inactive' ); } render(): React.Node { const active = InternalEntry.isActive(this.props, this.state); const { editing } = this.state; const threadColor = `#${this.props.threadInfo.color}`; const darkColor = colorIsDark(this.props.threadInfo.color); let actionLinks = null; if (active) { const actionLinksColor = darkColor ? '#D3D3D3' : '#404040'; const actionLinksTextStyle = { color: actionLinksColor }; const { modalIosHighlightUnderlay: actionLinksUnderlayColor } = darkColor ? colors.dark : colors.light; const loadingIndicatorCanUseRed = hueDistance('red', threadColor) > 50; let editButtonContent = null; if (editing && this.state.text.trim() === '') { // nothing } else if (editing) { editButtonContent = ( SAVE ); } else { editButtonContent = ( EDIT ); } actionLinks = ( ); } const textColor = darkColor ? 'white' : 'black'; let textInput; if (editing) { const textInputStyle = { color: textColor, backgroundColor: threadColor, }; const selectionColor = darkColor ? '#129AFF' : '#036AFF'; textInput = ( ); } let rawText = this.state.text; if (rawText === '' || rawText.slice(-1) === '\n') { rawText += ' '; } const textStyle = { ...this.props.styles.text, color: textColor, opacity: textInput ? 0 : 1, }; // We use an empty View to set the height of the entry, and then position // the Text and TextInput absolutely. This allows to measure height changes // to the Text while controlling the actual height of the entry. const heightStyle = { height: this.state.height }; const entryStyle = { backgroundColor: threadColor }; const opacity = editing ? 1.0 : 0.6; const canEditEntry = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_ENTRIES, ); return ( ); } textInputRef: (textInput: ?React.ElementRef) => void = textInput => { this.textInput = textInput; if (textInput && this.state.editing) { this.enterEditMode(); } }; enterEditMode: () => Promise = async () => { this.setActive(); this.props.onEnterEditMode(this.props.entryInfo); if (Platform.OS === 'android') { // If we don't do this, the TextInput focuses // but the soft keyboard doesn't come up await waitForInteractions(); await sleep(15); } this.focus(); }; focus: () => void = () => { const { textInput } = this; if (!textInput) { return; } textInput.focus(); }; onFocus: () => void = () => { if (this.props.threadPickerActive) { this.props.navigation.goBack(); } }; setActive: () => void = () => this.makeActive(true); completeEdit: () => void = () => { // This gets called from CalendarInputBar (save button above keyboard), // onPressEdit (save button in Entry action links), and in // componentDidUpdate above when Calendar sets this Entry to inactive. // Calendar does this if something else gets pressed or the Entry is // scrolled out of view. Note that an Entry won't consider itself inactive // until it's done updating the server with its state, and if the network // requests fail it may stay "active". if (this.textInput) { this.textInput.blur(); } this.onBlur(); }; onBlur: () => void = () => { if (this.state.text.trim() === '') { this.delete(); } else if (this.props.entryInfo.text !== this.state.text) { this.save(); } this.guardedSetState({ editing: false }); this.makeActive(false); this.props.onConcludeEditMode(this.props.entryInfo); }; save: () => void = () => { this.dispatchSave(this.props.entryInfo.id, this.state.text); }; onTextContainerLayout: (event: LayoutEvent) => void = event => { this.guardedSetState({ height: Math.ceil(event.nativeEvent.layout.height), }); }; onChangeText: (newText: string) => void = newText => { this.guardedSetState({ text: newText }); }; makeActive(active: boolean) { const { threadInfo } = this.props; if (!threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES)) { return; } this.props.makeActive(entryKey(this.props.entryInfo), active); } dispatchSave(serverID: ?string, newText: string) { if (this.currentlySaving === newText) { return; } this.currentlySaving = newText; if (newText.trim() === '') { // We don't save the empty string, since as soon as the element becomes // inactive it'll get deleted return; } if (!serverID) { if (this.creating) { // We need the first save call to return so we know the ID of the entry // we're updating, so we'll need to handle this save later this.needsUpdateAfterCreation = true; return; } else { this.creating = true; } } this.guardedSetState({ loadingStatus: 'loading' }); if (!serverID) { this.props.dispatchActionPromise( createEntryActionTypes, this.createAction(newText), ); } else { this.props.dispatchActionPromise( saveEntryActionTypes, this.saveAction(serverID, newText), ); } } async createAction(text: string): Promise { const localID = this.props.entryInfo.localID; invariant(localID, "if there's no serverID, there should be a localID"); const curSaveAttempt = this.nextSaveAttemptIndex++; try { const response = await this.props.createEntry({ text, timestamp: this.props.entryInfo.creationTime, date: dateString( this.props.entryInfo.year, this.props.entryInfo.month, this.props.entryInfo.day, ), threadID: this.props.entryInfo.threadID, localID, calendarQuery: this.props.calendarQuery(), }); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } this.creating = false; if (this.needsUpdateAfterCreation) { this.needsUpdateAfterCreation = false; this.dispatchSave(response.entryID, this.state.text); } if (this.needsDeleteAfterCreation) { this.needsDeleteAfterCreation = false; this.dispatchDelete(response.entryID); } return response; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; this.creating = false; throw e; } } async saveAction( entryID: string, newText: string, ): Promise { const curSaveAttempt = this.nextSaveAttemptIndex++; try { const response = await this.props.saveEntry({ entryID, text: newText, prevText: this.props.entryInfo.text, timestamp: Date.now(), calendarQuery: this.props.calendarQuery(), }); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } return { ...response, threadID: this.props.entryInfo.threadID }; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; if (e instanceof ServerError && e.message === 'concurrent_modification') { const revertedText = e.payload?.db; const onRefresh = () => { this.guardedSetState({ loadingStatus: 'inactive', text: revertedText, }); this.props.dispatch({ type: concurrentModificationResetActionType, payload: { id: entryID, dbText: revertedText }, }); }; Alert.alert( 'Concurrent modification', 'It looks like somebody is attempting to modify that field at the ' + 'same time as you! Please try again.', [{ text: 'OK', onPress: onRefresh }], { cancelable: false }, ); } throw e; } } delete: () => void = () => { this.dispatchDelete(this.props.entryInfo.id); }; onPressEdit: () => void = () => { if (this.state.editing) { this.completeEdit(); } else { this.guardedSetState({ editing: true }); } }; dispatchDelete(serverID: ?string) { if (this.deleted) { return; } this.deleted = true; LayoutAnimation.easeInEaseOut(); const { localID } = this.props.entryInfo; this.props.dispatchActionPromise( deleteEntryActionTypes, this.deleteAction(serverID), undefined, { localID, serverID }, ); } async deleteAction(serverID: ?string): Promise { if (serverID) { return await this.props.deleteEntry({ entryID: serverID, prevText: this.props.entryInfo.text, calendarQuery: this.props.calendarQuery(), }); } else if (this.creating) { this.needsDeleteAfterCreation = true; } return null; } onPressThreadName: () => void = () => { Keyboard.dismiss(); this.props.navigateToThread({ threadInfo: this.props.threadInfo }); }; } const unboundStyles = { actionLinks: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', marginTop: -5, }, button: { padding: 5, }, buttonContents: { flex: 1, flexDirection: 'row', }, container: { backgroundColor: 'listBackground', }, entry: { borderRadius: 8, margin: 5, overflow: 'hidden', }, leftLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-start', paddingHorizontal: 5, }, leftLinksText: { fontSize: 12, fontWeight: 'bold', paddingLeft: 5, }, pencilIcon: { lineHeight: 13, paddingTop: 1, }, rightLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', paddingHorizontal: 5, }, rightLinksText: { fontSize: 12, fontWeight: 'bold', }, text: { fontFamily: 'System', fontSize: 16, }, textContainer: { position: 'absolute', top: 0, paddingBottom: 6, paddingLeft: 10, paddingRight: 10, paddingTop: 5, transform: (Platform.select({ ios: [{ translateY: -1 / 3 }], default: [], }): $ReadOnlyArray<{ +translateY: number }>), }, textInput: { fontFamily: 'System', fontSize: 16, left: ((Platform.OS === 'android' ? 9.8 : 10): number), margin: 0, padding: 0, position: 'absolute', right: 10, top: ((Platform.OS === 'android' ? 4.8 : 0.5): number), }, }; registerFetchKey(saveEntryActionTypes); registerFetchKey(deleteEntryActionTypes); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const Entry: React.ComponentType = React.memo( function ConnectedEntry(props: BaseProps) { const navContext = React.useContext(NavContext); const threadPickerActive = activeThreadPickerSelector(navContext); const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const connection = useSelector(connectionSelector(ashoatKeyserverID)); invariant(connection, 'keyserver missing from keyserverStore'); const online = connection.status === 'connected'; const styles = useStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callCreateEntry = useCreateEntry(); const callSaveEntry = useSaveEntry(); const callDeleteEntry = useDeleteEntry(); const { threadInfo: unresolvedThreadInfo, ...restProps } = props; const threadInfo = useResolvedThreadInfo(unresolvedThreadInfo); return ( ); }, ); export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement }; diff --git a/native/chat/chat-context-provider.react.js b/native/chat/chat-context-provider.react.js index e7ebef09a..e8703914a 100644 --- a/native/chat/chat-context-provider.react.js +++ b/native/chat/chat-context-provider.react.js @@ -1,173 +1,174 @@ // @flow import * as React from 'react'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { ChatContext } from './chat-context.js'; import type { SidebarAnimationType } from './chat-context.js'; import ChatItemHeightMeasurer from './chat-item-height-measurer.react.js'; import type { NativeChatMessageItem } from './message-data.react.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; type Props = { +children: React.Node, }; export type MeasurementTask = { +messages: $ReadOnlyArray, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +onMessagesMeasured: ( messagesWithHeight: $ReadOnlyArray, measuredHeights: $ReadOnlyMap, ) => mixed, +measurerID: number, +initialMeasuredHeights: ?$ReadOnlyMap, }; function ChatContextProvider(props: Props): React.Node { const [measurements, setMeasurements] = React.useState< $ReadOnlyArray, >([]); const nextMeasurerID = React.useRef(0); const measuredHeights = React.useRef< Map>, >(new Map()); const measureMessages = React.useCallback( ( messages: ?$ReadOnlyArray, - threadInfo: ?ThreadInfo, + threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, onMessagesMeasured: ($ReadOnlyArray) => mixed, measurerID: number, ) => { if (!threadInfo) { // When threadInfo is not present, we can't measure the messages: we can // determine the height, but we can't merge the result as it requires // threadInfo to be present. return; } if (!messages) { return; } const measureCallback = ( messagesWithHeight: $ReadOnlyArray, newMeasuredHeights: $ReadOnlyMap, ) => { measuredHeights.current.set(measurerID, newMeasuredHeights); onMessagesMeasured(messagesWithHeight); }; let initialMeasuredHeights = null; const isMeasurementPresent = measuredHeights.current.has(measurerID); if (!isMeasurementPresent) { const sourceMeasurerID = measurements.find( measurement => measurement.threadInfo.id === threadInfo.id, )?.measurerID; initialMeasuredHeights = sourceMeasurerID ? measuredHeights.current.get(sourceMeasurerID) : null; } const newMeasurement = { messages, threadInfo, onMessagesMeasured: measureCallback, measurerID, initialMeasuredHeights, }; setMeasurements(prevMeasurements => { const withoutCurrentMeasurement = prevMeasurements.filter( measurement => measurement.measurerID !== measurerID, ); return [...withoutCurrentMeasurement, newMeasurement]; }); }, [measurements], ); const registerMeasurer = React.useCallback(() => { const measurerID = nextMeasurerID.current++; return { measure: ( messages: ?$ReadOnlyArray, - threadInfo: ?ThreadInfo, + threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, onMessagesMeasured: ( $ReadOnlyArray, ) => mixed, ) => measureMessages(messages, threadInfo, onMessagesMeasured, measurerID), unregister: () => { setMeasurements(prevMeasurements => prevMeasurements.filter( measurement => measurement.measurerID !== measurerID, ), ); measuredHeights.current.delete(measurerID); }, }; }, [measureMessages]); const [ currentTransitionSidebarSourceID, setCurrentTransitionSidebarSourceID, ] = React.useState(null); const chatInputBarHeights = React.useRef>(new Map()); const setChatInputBarHeight = React.useCallback( (threadID: string, height: number) => chatInputBarHeights.current.set(threadID, height), [], ); const deleteChatInputBarHeight = React.useCallback( (threadID: string) => chatInputBarHeights.current.delete(threadID), [], ); const [sidebarAnimationType, setSidebarAnimationType] = React.useState('move_source_message'); const contextValue = React.useMemo( () => ({ registerMeasurer, currentTransitionSidebarSourceID, setCurrentTransitionSidebarSourceID, setChatInputBarHeight, deleteChatInputBarHeight, chatInputBarHeights: chatInputBarHeights.current, sidebarAnimationType, setSidebarAnimationType, }), [ currentTransitionSidebarSourceID, deleteChatInputBarHeight, registerMeasurer, setChatInputBarHeight, sidebarAnimationType, ], ); const heightMeasurers = React.useMemo( () => measurements.map(measurement => ( )), [measurements], ); return ( {heightMeasurers} {props.children} ); } const MemoizedChatContextProvider: React.ComponentType = React.memo(ChatContextProvider); export default MemoizedChatContextProvider; diff --git a/native/chat/chat-context.js b/native/chat/chat-context.js index a23baa972..cb1da7ce1 100644 --- a/native/chat/chat-context.js +++ b/native/chat/chat-context.js @@ -1,53 +1,54 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import type { SetState } from 'lib/types/hook-types.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import type { NativeChatMessageItem } from './message-data.react.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; export type MessagesMeasurer = ( ?$ReadOnlyArray, - ?ThreadInfo, + ?ThreadInfo | ?MinimallyEncodedThreadInfo, ($ReadOnlyArray) => mixed, ) => void; export type RegisteredMeasurer = { +measure: MessagesMeasurer, +unregister: () => void, }; export type SidebarAnimationType = | 'fade_source_message' | 'move_source_message'; export type ChatContextType = { +registerMeasurer: () => RegisteredMeasurer, +currentTransitionSidebarSourceID: ?string, +setCurrentTransitionSidebarSourceID: SetState, +setChatInputBarHeight: (threadID: string, height: number) => mixed, +deleteChatInputBarHeight: (threadID: string) => mixed, +chatInputBarHeights: $ReadOnlyMap, +sidebarAnimationType: SidebarAnimationType, +setSidebarAnimationType: (animationType: SidebarAnimationType) => mixed, }; const ChatContext: React.Context = React.createContext(null); function useHeightMeasurer(): MessagesMeasurer { const chatContext = React.useContext(ChatContext); invariant(chatContext, 'Chat context should be set'); const measureRegistrationRef = React.useRef(); if (!measureRegistrationRef.current) { measureRegistrationRef.current = chatContext.registerMeasurer(); } const measureRegistration = measureRegistrationRef.current; React.useEffect(() => { return measureRegistration.unregister; }, [measureRegistration]); return measureRegistration.measure; } export { ChatContext, useHeightMeasurer }; diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index a8a9aa2ba..2e118fea1 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,1442 +1,1448 @@ // @flow import Icon from '@expo/vector-icons/Ionicons.js'; import invariant from 'invariant'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import { View, TextInput, TouchableOpacity, Platform, Text, ActivityIndicator, TouchableWithoutFeedback, NativeAppEventEmitter, } from 'react-native'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import Animated, { EasingNode, FadeInDown, FadeOutDown, } from 'react-native-reanimated'; import { useDispatch } from 'react-redux'; import { moveDraftActionType, updateDraftActionType, } from 'lib/actions/draft-actions.js'; import { joinThreadActionTypes, useJoinThread, newThreadActionTypes, } from 'lib/actions/thread-actions.js'; import { useChatMentionContext, useThreadChatMentionCandidates, } from 'lib/hooks/chat-mention-hooks.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userStoreMentionSearchIndex } from 'lib/selectors/user-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { useEditMessage } from 'lib/shared/edit-messages-utils.js'; import { getMentionTypeaheadUserSuggestions, getMentionTypeaheadChatSuggestions, getTypeaheadRegexMatches, type Selection, getUserMentionsCandidates, } from 'lib/shared/mention-utils.js'; import { useNextLocalID, trimMessage, useMessagePreview, messageKey, type MessagePreviewResult, } from 'lib/shared/message-utils.js'; import SentencePrefixSearchIndex from 'lib/shared/sentence-prefix-search-index.js'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { PhotoPaste } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { SendEditMessageResponse, MessageInfo, } from 'lib/types/message-types.js'; +import type { + MinimallyEncodedRelativeMemberInfo, + MinimallyEncodedThreadInfo, +} from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { ThreadInfo, ClientThreadJoinRequest, ThreadJoinPayload, RelativeMemberInfo, ChatMentionCandidates, } from 'lib/types/thread-types.js'; import { type UserInfos } from 'lib/types/user-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { ChatContext } from './chat-context.js'; import type { ChatNavigationProp } from './chat.react.js'; import { MessageEditingContext, type MessageEditingContextType, } from './message-editing-context.react.js'; import type { RemoveEditMode } from './message-list-types.js'; import TypeaheadTooltip from './typeahead-tooltip.react.js'; import MentionTypeaheadTooltipButton from '../chat/mention-typeahead-tooltip-button.react.js'; import Button from '../components/button.react.js'; // eslint-disable-next-line import/extensions import ClearableTextInput from '../components/clearable-text-input.react'; import type { SyncedSelectionData } from '../components/selectable-text-input.js'; // eslint-disable-next-line import/extensions import SelectableTextInput from '../components/selectable-text-input.react'; import SingleLine from '../components/single-line.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { type InputState, InputStateContext, type EditInputBarMessageParameters, } from '../input/input-state.js'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react.js'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import { getKeyboardHeight } from '../keyboard/keyboard.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import { nonThreadCalendarQuery, activeThreadSelector, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import type { OverlayContextType } from '../navigation/overlay-context.js'; import { type NavigationRoute, ChatCameraModalRouteName, ImagePasteModalRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useStyles, useColors } from '../themes/colors.js'; import type { LayoutEvent } from '../types/react-native.js'; import { type AnimatedViewStyle, AnimatedView } from '../types/styles.js'; import Alert from '../utils/alert.js'; import { runTiming } from '../utils/animation-utils.js'; import { exitEditAlert } from '../utils/edit-messages-utils.js'; import { nativeMentionTypeaheadRegex, mentionTypeaheadTooltipActions, } from '../utils/typeahead-utils.js'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, cond, neq, sub, interpolateNode, stopClock } = Animated; /* eslint-enable import/no-named-as-default-member */ const expandoButtonsAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; type BaseProps = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, }; type Props = { ...BaseProps, +viewerID: ?string, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +nextLocalID: string, +userInfos: UserInfos, +colors: Colors, +styles: typeof unboundStyles, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, +isActive: boolean, +keyboardState: ?KeyboardState, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +joinThread: (request: ClientThreadJoinRequest) => Promise, +inputState: ?InputState, +userSearchIndex: SentencePrefixSearchIndex, - +userMentionsCandidates: $ReadOnlyArray, + +userMentionsCandidates: $ReadOnlyArray< + RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo, + >, +chatMentionSearchIndex: SentencePrefixSearchIndex, +chatMentionCandidates: ChatMentionCandidates, +parentThreadInfo: ?ThreadInfo, +editedMessagePreview: ?MessagePreviewResult, +editedMessageInfo: ?MessageInfo, +editMessage: ( messageID: string, text: string, ) => Promise, +navigation: ?ChatNavigationProp<'MessageList'>, +overlayContext: ?OverlayContextType, +messageEditingContext: ?MessageEditingContextType, }; type State = { +text: string, +textEdited: boolean, +buttonsExpanded: boolean, +selectionState: SyncedSelectionData, +isExitingDuringEditMode: boolean, }; class ChatInputBar extends React.PureComponent { textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; selectableTextInput: ?React.ElementRef; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: AnimatedViewStyle; cameraRollIconStyle: AnimatedViewStyle; cameraIconStyle: AnimatedViewStyle; expandIconStyle: AnimatedViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: AnimatedViewStyle; clearBeforeRemoveListener: () => void; clearFocusListener: () => void; clearBlurListener: () => void; constructor(props: Props) { super(props); this.state = { text: props.draft, textEdited: false, buttonsExpanded: true, selectionState: { text: props.draft, selection: { start: 0, end: 0 } }, isExitingDuringEditMode: false, }; this.setUpActionIconAnimations(); this.setUpSendIconAnimations(); } setUpActionIconAnimations() { this.expandoButtonsOpen = new Value(1); this.targetExpandoButtonsOpen = new Value(1); const prevTargetExpandoButtonsOpen = new Value(1); const expandoButtonClock = new Clock(); const expandoButtonsOpen = block([ cond(neq(this.targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [ stopClock(expandoButtonClock), set(prevTargetExpandoButtonsOpen, this.targetExpandoButtonsOpen), ]), cond( neq(this.expandoButtonsOpen, this.targetExpandoButtonsOpen), set( this.expandoButtonsOpen, runTiming( expandoButtonClock, this.expandoButtonsOpen, this.targetExpandoButtonsOpen, true, expandoButtonsAnimationConfig, ), ), ), this.expandoButtonsOpen, ]); this.cameraRollIconStyle = { ...unboundStyles.cameraRollIcon, opacity: expandoButtonsOpen, }; this.cameraIconStyle = { ...unboundStyles.cameraIcon, opacity: expandoButtonsOpen, }; const expandoButtonsWidth = interpolateNode(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [26, 66], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; } setUpSendIconAnimations() { const initialSendButtonContainerOpen = trimMessage(this.props.draft) ? 1 : 0; this.sendButtonContainerOpen = new Value(initialSendButtonContainerOpen); this.targetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const prevTargetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const sendButtonClock = new Clock(); const sendButtonContainerOpen = block([ cond( neq( this.targetSendButtonContainerOpen, prevTargetSendButtonContainerOpen, ), [ stopClock(sendButtonClock), set( prevTargetSendButtonContainerOpen, this.targetSendButtonContainerOpen, ), ], ), cond( neq(this.sendButtonContainerOpen, this.targetSendButtonContainerOpen), set( this.sendButtonContainerOpen, runTiming( sendButtonClock, this.sendButtonContainerOpen, this.targetSendButtonContainerOpen, true, sendButtonAnimationConfig, ), ), ), this.sendButtonContainerOpen, ]); const sendButtonContainerWidth = interpolateNode(sendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }); this.sendButtonContainerStyle = { width: sendButtonContainerWidth }; } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing() { return ChatInputBar.systemKeyboardShowing(this.props); } immediatelyShowSendButton() { this.sendButtonContainerOpen.setValue(1); this.targetSendButtonContainerOpen.setValue(1); } updateSendButton(currentText: string) { if (this.shouldShowTextInput) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } else { this.setUpSendIconAnimations(); } } componentDidMount() { const { isActive, navigation } = this.props; if (isActive) { this.addEditInputMessageListener(); } if (!navigation) { return; } this.clearBeforeRemoveListener = navigation.addListener( 'beforeRemove', this.onNavigationBeforeRemove, ); this.clearFocusListener = navigation.addListener( 'focus', this.onNavigationFocus, ); this.clearBlurListener = navigation.addListener( 'blur', this.onNavigationBlur, ); } componentWillUnmount() { if (this.props.isActive) { this.removeEditInputMessageListener(); } if (this.clearBeforeRemoveListener) { this.clearBeforeRemoveListener(); } if (this.clearFocusListener) { this.clearFocusListener(); } if (this.clearBlurListener) { this.clearBlurListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.state.textEdited && this.state.text && this.props.threadInfo.id !== prevProps.threadInfo.id ) { this.props.dispatch({ type: moveDraftActionType, payload: { oldKey: draftKeyFromThreadID(prevProps.threadInfo.id), newKey: draftKeyFromThreadID(this.props.threadInfo.id), }, }); } else if (!this.state.textEdited && this.props.draft !== prevProps.draft) { this.setState({ text: this.props.draft }); } if (this.props.isActive && !prevProps.isActive) { this.addEditInputMessageListener(); } else if (!this.props.isActive && prevProps.isActive) { this.removeEditInputMessageListener(); } const currentText = trimMessage(this.state.text); const prevText = trimMessage(prevState.text); if ( (currentText === '' && prevText !== '') || (currentText !== '' && prevText === '') ) { this.updateSendButton(currentText); } const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing( this.props, ); const systemKeyboardWasShowing = ChatInputBar.systemKeyboardShowing(prevProps); if (systemKeyboardIsShowing && !systemKeyboardWasShowing) { this.hideButtons(); } else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) { this.expandButtons(); } const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props); const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps); if (!imageGalleryIsOpen && imageGalleryWasOpen) { this.hideButtons(); } else if (imageGalleryIsOpen && !imageGalleryWasOpen) { this.expandButtons(); this.setIOSKeyboardHeight(); } if ( this.props.messageEditingContext?.editState.editedMessage && !prevProps.messageEditingContext?.editState.editedMessage ) { this.blockNavigation(); } } addEditInputMessageListener() { invariant( this.props.inputState, 'inputState should be set in addEditInputMessageListener', ); this.props.inputState.addEditInputMessageListener(this.focusAndUpdateText); } removeEditInputMessageListener() { invariant( this.props.inputState, 'inputState should be set in removeEditInputMessageListener', ); this.props.inputState.removeEditInputMessageListener( this.focusAndUpdateText, ); } setIOSKeyboardHeight() { if (Platform.OS !== 'ios') { return; } const { textInput } = this; if (!textInput) { return; } const keyboardHeight = getKeyboardHeight(); if (keyboardHeight === null || keyboardHeight === undefined) { return; } TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight); } get shouldShowTextInput(): boolean { if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { return true; } // If the thread is created by somebody else while the viewer is attempting // to create it, the threadInfo might be modified in-place // and won't list the viewer as a member, // which will end up hiding the input. // In this case, we will assume that our creation action // will get translated into a join, and as long // as members are voiced, we can show the input. if (!this.props.threadCreationInProgress) { return false; } return checkIfDefaultMembersAreVoiced(this.props.threadInfo); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; const threadColor = `#${this.props.threadInfo.color}`; const isEditMode = this.isEditMode(); if (!isMember && canJoin && !this.props.threadCreationInProgress) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { const textStyle = colorIsDark(this.props.threadInfo.color) ? this.props.styles.joinButtonTextLight : this.props.styles.joinButtonTextDark; buttonContent = ( Join Chat ); } joinButton = ( ); } const typeaheadRegexMatches = getTypeaheadRegexMatches( this.state.selectionState.text, this.state.selectionState.selection, nativeMentionTypeaheadRegex, ); let typeaheadTooltip = null; if (typeaheadRegexMatches && !isEditMode) { const typeaheadMatchedStrings = { textBeforeAtSymbol: typeaheadRegexMatches[1] ?? '', query: typeaheadRegexMatches[4] ?? '', }; const suggestedUsers = getMentionTypeaheadUserSuggestions( this.props.userSearchIndex, this.props.userMentionsCandidates, this.props.viewerID, typeaheadMatchedStrings.query, ); const suggestedChats = getMentionTypeaheadChatSuggestions( this.props.chatMentionSearchIndex, this.props.chatMentionCandidates, typeaheadMatchedStrings.query, ); const suggestions = [...suggestedUsers, ...suggestedChats]; if (suggestions.length > 0) { typeaheadTooltip = ( ); } } let content; const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); if (this.shouldShowTextInput) { content = this.renderInput(); } else if ( threadFrozenDueToViewerBlock( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ) && threadActualMembers(this.props.threadInfo.members).length === 2 ) { content = ( You can’t send messages to a user that you’ve blocked. ); } else if (isMember) { content = ( You don’t have permission to send messages. ); } else if (defaultMembersAreVoiced && canJoin) { content = null; } else { content = ( You don’t have permission to send messages. ); } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); let editedMessage; if (isEditMode && this.props.editedMessagePreview) { const { message } = this.props.editedMessagePreview; editedMessage = ( Editing message {message.text} ); } return ( {typeaheadTooltip} {joinButton} {editedMessage} {content} {keyboardInputHost} ); } renderInput() { const expandoButton = ( ); const threadColor = `#${this.props.threadInfo.color}`; const expandoButtonsViewStyle = [this.props.styles.innerExpandoButtons]; if (this.isEditMode()) { expandoButtonsViewStyle.push({ display: 'none' }); } return ( {this.state.buttonsExpanded ? expandoButton : null} {this.state.buttonsExpanded ? null : expandoButton} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => { this.clearableTextInput = clearableTextInput; }; selectableTextInputRef = ( selectableTextInput: ?React.ElementRef, ) => { this.selectableTextInput = selectableTextInput; }; updateText = (text: string) => { if (this.state.isExitingDuringEditMode) { return; } this.setState({ text, textEdited: true }); this.props.messageEditingContext?.setEditedMessageChanged( this.isMessageEdited(text), ); if (this.isEditMode()) { return; } this.saveDraft(text); }; updateSelectionState: (data: SyncedSelectionData) => void = data => { this.setState({ selectionState: data }); }; saveDraft = _throttle(text => { this.props.dispatch({ type: updateDraftActionType, payload: { key: draftKeyFromThreadID(this.props.threadInfo.id), text, }, }); }, 400); focusAndUpdateTextAndSelection = (text: string, selection: Selection) => { this.selectableTextInput?.prepareForSelectionMutation(text, selection); this.setState({ text, textEdited: true, selectionState: { text, selection }, }); this.saveDraft(text); this.focusAndUpdateButtonsVisibility(); }; focusAndUpdateText = (params: EditInputBarMessageParameters) => { const { message: text, mode } = params; const currentText = this.state.text; if (mode === 'replace') { this.updateText(text); } else if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); } this.focusAndUpdateButtonsVisibility(); }; focusAndUpdateButtonsVisibility = () => { const { textInput } = this; if (!textInput) { return; } this.immediatelyShowSendButton(); this.immediatelyHideButtons(); textInput.focus(); }; onSend = async () => { if (!trimMessage(this.state.text)) { return; } const editedMessage = this.getEditedMessage(); if (editedMessage && editedMessage.id) { this.editMessage(editedMessage.id, this.state.text); return; } this.updateSendButton(''); const { clearableTextInput } = this; invariant( clearableTextInput, 'clearableTextInput should be sent in onSend', ); let text = await clearableTextInput.getValueAndReset(); text = trimMessage(text); if (!text) { return; } const localID = this.props.nextLocalID; const creatorID = this.props.viewerID; invariant(creatorID, 'should have viewer ID in order to send a message'); invariant( this.props.inputState, 'inputState should be set in ChatInputBar.onSend', ); this.props.inputState.sendTextMessage( { type: messageTypes.TEXT, localID, threadID: this.props.threadInfo.id, text, creatorID, time: Date.now(), }, this.props.threadInfo, this.props.parentThreadInfo, ); }; isEditMode = () => { const editState = this.props.messageEditingContext?.editState; const isThisThread = editState?.editedMessage?.threadID === this.props.threadInfo.id; return editState && editState.editedMessage !== null && isThisThread; }; isMessageEdited = newText => { let text = newText ?? this.state.text; text = trimMessage(text); const originalText = this.props.editedMessageInfo?.text; return text !== originalText; }; unblockNavigation = () => { const { navigation } = this.props; if (!navigation) { return; } navigation.setParams({ removeEditMode: null }); }; removeEditMode: RemoveEditMode = action => { const { navigation } = this.props; if (!navigation || this.state.isExitingDuringEditMode) { return 'ignore_action'; } if (!this.isMessageEdited()) { this.unblockNavigation(); return 'reduce_action'; } const unblockAndDispatch = () => { this.unblockNavigation(); navigation.dispatch(action); }; const onContinueEditing = () => { this.props.overlayContext?.resetScrollBlockingModalStatus(); }; exitEditAlert({ onDiscard: unblockAndDispatch, onContinueEditing, }); return 'ignore_action'; }; blockNavigation = () => { const { navigation } = this.props; if (!navigation || !navigation.isFocused()) { return; } navigation.setParams({ removeEditMode: this.removeEditMode, }); }; editMessage = async (messageID: string, text: string) => { if (!this.isMessageEdited()) { this.exitEditMode(); return; } text = trimMessage(text); try { await this.props.editMessage(messageID, text); this.exitEditMode(); } catch (error) { Alert.alert( 'Couldn’t edit the message', 'Please try again later', [{ text: 'OK' }], { cancelable: true, }, ); } }; getEditedMessage = () => { const editState = this.props.messageEditingContext?.editState; return editState?.editedMessage; }; onPressExitEditMode = () => { if (!this.isMessageEdited()) { this.exitEditMode(); return; } exitEditAlert({ onDiscard: this.exitEditMode, }); }; scrollToEditedMessage = () => { const editedMessage = this.getEditedMessage(); if (!editedMessage) { return; } const editedMessageKey = messageKey(editedMessage); this.props.inputState?.scrollToMessage(editedMessageKey); }; exitEditMode = () => { this.props.messageEditingContext?.setEditedMessage(null, () => { this.unblockNavigation(); this.updateText(this.props.draft); this.focusAndUpdateButtonsVisibility(); this.updateSendButton(this.props.draft); }); }; onNavigationFocus = () => { this.setState({ isExitingDuringEditMode: false }); }; onNavigationBlur = () => { if (!this.isEditMode()) { return; } this.setState( { text: this.props.draft, isExitingDuringEditMode: true }, this.exitEditMode, ); }; onNavigationBeforeRemove = e => { if (!this.isEditMode()) { return; } const { action } = e.data; e.preventDefault(); const saveExit = () => { this.props.messageEditingContext?.setEditedMessage(null, () => { this.setState({ isExitingDuringEditMode: true }, () => { if (!this.props.navigation) { return; } this.props.navigation.dispatch(action); }); }); }; if (!this.isMessageEdited()) { saveExit(); return; } exitEditAlert({ onDiscard: saveExit, }); }; onPressJoin = () => { this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction()); }; async joinAction() { const query = this.props.calendarQuery(); return await this.props.joinThread({ threadID: this.props.threadInfo.id, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [this.props.threadInfo.id] }, ], }, }); } expandButtons = () => { if (this.state.buttonsExpanded || this.isEditMode()) { return; } this.targetExpandoButtonsOpen.setValue(1); this.setState({ buttonsExpanded: true }); }; hideButtons() { if ( ChatInputBar.mediaGalleryOpen(this.props) || !this.systemKeyboardShowing || !this.state.buttonsExpanded ) { return; } this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } immediatelyHideButtons() { this.expandoButtonsOpen.setValue(0); this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery(this.props.threadInfo); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const unboundStyles = { cameraIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, cameraRollIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, container: { backgroundColor: 'listBackground', paddingLeft: Platform.OS === 'android' ? 10 : 5, }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'android' ? 13 : 11, paddingRight: 2, }, expandoButtons: { alignSelf: 'flex-end', }, explanation: { color: 'listBackgroundSecondaryLabel', paddingBottom: 4, paddingTop: 1, textAlign: 'center', }, innerExpandoButtons: { alignItems: 'flex-end', alignSelf: 'flex-end', flexDirection: 'row', }, inputContainer: { flexDirection: 'row', }, joinButton: { borderRadius: 8, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, }, joinButtonContainer: { flexDirection: 'row', height: 48, marginBottom: 8, }, editView: { marginLeft: 20, marginRight: 20, padding: 10, flexDirection: 'row', justifyContent: 'space-between', }, editViewContent: { flex: 1, paddingRight: 6, }, exitEditButton: { marginTop: 6, }, editingLabel: { paddingBottom: 4, }, editingMessagePreview: { color: 'listForegroundLabel', }, joinButtonContent: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', }, joinButtonTextLight: { color: 'white', fontSize: 20, marginHorizontal: 4, }, joinButtonTextDark: { color: 'black', fontSize: 20, marginHorizontal: 4, }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: 4, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 6, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 8, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginRight: 4, marginTop: 6, marginBottom: 8, maxHeight: 110, paddingHorizontal: 10, paddingVertical: 5, }, }; const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); const createThreadLoadingStatusSelector = createLoadingStatusSelector(newThreadActionTypes); type ConnectedChatInputBarBaseProps = { ...BaseProps, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, +navigation?: ChatNavigationProp<'MessageList'>, }; function ConnectedChatInputBarBase(props: ConnectedChatInputBarBaseProps) { const navContext = React.useContext(NavContext); const keyboardState = React.useContext(KeyboardContext); const inputState = React.useContext(InputStateContext); const overlayContext = React.useContext(OverlayContext); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const draft = useSelector( state => state.draftStore.drafts[draftKeyFromThreadID(props.threadInfo.id)] ?? '', ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const createThreadLoadingStatus = useSelector( createThreadLoadingStatusSelector, ); const threadCreationInProgress = createThreadLoadingStatus === 'loading'; const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const nextLocalID = useNextLocalID(); const userInfos = useSelector(state => state.userStore.userInfos); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useJoinThread(); const userSearchIndex = useSelector(userStoreMentionSearchIndex); const { getChatMentionSearchIndex } = useChatMentionContext(); const chatMentionSearchIndex = getChatMentionSearchIndex(props.threadInfo); const { parentThreadID } = props.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const userMentionsCandidates = getUserMentionsCandidates( props.threadInfo, parentThreadInfo, ); const chatMentionCandidates = useThreadChatMentionCandidates( props.threadInfo, ); const messageEditingContext = React.useContext(MessageEditingContext); const editedMessageInfo = messageEditingContext?.editState.editedMessage; const editedMessagePreview = useMessagePreview( editedMessageInfo, props.threadInfo, getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules, ); const editMessage = useEditMessage(); return ( ); } type DummyChatInputBarProps = { ...BaseProps, +onHeightMeasured: (height: number) => mixed, }; const noop = () => {}; function DummyChatInputBar(props: DummyChatInputBarProps): React.Node { const { onHeightMeasured, ...restProps } = props; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; onHeightMeasured(height); }, [onHeightMeasured], ); return ( ); } type ChatInputBarProps = { ...BaseProps, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; const ConnectedChatInputBar: React.ComponentType = React.memo(function ConnectedChatInputBar( props: ChatInputBarProps, ) { const { navigation, route, ...restProps } = props; const keyboardState = React.useContext(KeyboardContext); const { threadInfo } = props; const imagePastedCallback = React.useCallback( imagePastedEvent => { if (threadInfo.id !== imagePastedEvent.threadID) { return; } const pastedImage: PhotoPaste = { step: 'photo_paste', dimensions: { height: imagePastedEvent.height, width: imagePastedEvent.width, }, filename: imagePastedEvent.fileName, uri: 'file://' + imagePastedEvent.filePath, selectTime: 0, sendTime: 0, retries: 0, }; navigation.navigate<'ImagePasteModal'>({ name: ImagePasteModalRouteName, params: { imagePasteStagingInfo: pastedImage, thread: threadInfo, }, }); }, [navigation, threadInfo], ); React.useEffect(() => { const imagePasteListener = NativeAppEventEmitter.addListener( 'imagePasted', imagePastedCallback, ); return () => imagePasteListener.remove(); }, [imagePastedCallback]); const chatContext = React.useContext(ChatContext); invariant(chatContext, 'should be set'); const { setChatInputBarHeight, deleteChatInputBarHeight } = chatContext; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; setChatInputBarHeight(threadInfo.id, height); }, [threadInfo.id, setChatInputBarHeight], ); React.useEffect(() => { return () => { deleteChatInputBarHeight(threadInfo.id); }; }, [deleteChatInputBarHeight, threadInfo.id]); const openCamera = React.useCallback(() => { keyboardState?.dismissKeyboard(); navigation.navigate<'ChatCameraModal'>({ name: ChatCameraModalRouteName, params: { presentedFrom: route.key, thread: threadInfo, }, }); }, [keyboardState, navigation, route.key, threadInfo]); return ( ); }); export { ConnectedChatInputBar as ChatInputBar, DummyChatInputBar }; diff --git a/native/chat/compose-subchannel.react.js b/native/chat/compose-subchannel.react.js index a3b834ce7..607beed8a 100644 --- a/native/chat/compose-subchannel.react.js +++ b/native/chat/compose-subchannel.react.js @@ -1,383 +1,384 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _sortBy from 'lodash/fp/sortBy.js'; import * as React from 'react'; import { View, Text } from 'react-native'; import { newThreadActionTypes, useNewThread, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors.js'; import { getPotentialMemberItems } from 'lib/shared/search-utils.js'; import { threadInFilterList, userIsMember } from 'lib/shared/thread-utils.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ThreadType, threadTypes } from 'lib/types/thread-types-enum.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { type AccountUserInfo } from 'lib/types/user-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import type { ChatNavigationProp } from './chat.react.js'; import { useNavigateToThread } from './message-list-types.js'; import ParentThreadHeader from './parent-thread-header.react.js'; import LinkButton from '../components/link-button.react.js'; import { createTagInput } from '../components/tag-input.react.js'; import ThreadList from '../components/thread-list.react.js'; import UserList from '../components/user-list.react.js'; import { useCalendarQuery } from '../navigation/nav-selectors.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; export type ComposeSubchannelParams = { +threadType: ThreadType, - +parentThreadInfo: ThreadInfo, + +parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, }; type Props = { +navigation: ChatNavigationProp<'ComposeSubchannel'>, +route: NavigationRoute<'ComposeSubchannel'>, }; function ComposeSubchannel(props: Props): React.Node { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const [createButtonEnabled, setCreateButtonEnabled] = React.useState(true); const tagInputRef = React.useRef(); const onUnknownErrorAlertAcknowledged = React.useCallback(() => { setUsernameInputText(''); tagInputRef.current?.focus(); }, []); const waitingOnThreadIDRef = React.useRef(); const { threadType, parentThreadInfo } = props.route.params; const userInfoInputIDs = userInfoInputArray.map(userInfo => userInfo.id); const callNewThread = useNewThread(); const calendarQuery = useCalendarQuery(); const newChatThreadAction = React.useCallback(async () => { try { const assumedThreadType = threadType ?? threadTypes.COMMUNITY_SECRET_SUBTHREAD; const query = calendarQuery(); invariant( assumedThreadType === 3 || assumedThreadType === 4 || assumedThreadType === 6 || assumedThreadType === 7, "Sidebars and communities can't be created from the thread composer", ); const result = await callNewThread({ type: assumedThreadType, parentThreadID: parentThreadInfo.id, initialMemberIDs: userInfoInputIDs, color: parentThreadInfo.color, calendarQuery: query, }); waitingOnThreadIDRef.current = result.newThreadID; return result; } catch (e) { setCreateButtonEnabled(true); Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } }, [ threadType, userInfoInputIDs, calendarQuery, parentThreadInfo, callNewThread, onUnknownErrorAlertAcknowledged, ]); const dispatchActionPromise = useDispatchActionPromise(); const dispatchNewChatThreadAction = React.useCallback(() => { setCreateButtonEnabled(false); dispatchActionPromise(newThreadActionTypes, newChatThreadAction()); }, [dispatchActionPromise, newChatThreadAction]); const userInfoInputArrayEmpty = userInfoInputArray.length === 0; const onPressCreateThread = React.useCallback(() => { if (!createButtonEnabled) { return; } if (userInfoInputArrayEmpty) { Alert.alert( 'Chatting to yourself?', 'Are you sure you want to create a channel containing only yourself?', [ { text: 'Cancel', style: 'cancel' }, { text: 'Confirm', onPress: dispatchNewChatThreadAction }, ], { cancelable: true }, ); } else { dispatchNewChatThreadAction(); } }, [ createButtonEnabled, userInfoInputArrayEmpty, dispatchNewChatThreadAction, ]); const { navigation } = props; const { setOptions } = navigation; React.useEffect(() => { setOptions({ // eslint-disable-next-line react/display-name headerRight: () => ( ), }); }, [setOptions, onPressCreateThread, createButtonEnabled]); const { setParams } = navigation; const parentThreadInfoID = parentThreadInfo.id; const reduxParentThreadInfo = useSelector( state => threadInfoSelector(state)[parentThreadInfoID], ); React.useEffect(() => { if (reduxParentThreadInfo) { setParams({ parentThreadInfo: reduxParentThreadInfo }); } }, [reduxParentThreadInfo, setParams]); const threadInfos = useSelector(threadInfoSelector); const newlyCreatedThreadInfo = waitingOnThreadIDRef.current ? threadInfos[waitingOnThreadIDRef.current] : null; const { pushNewThread } = navigation; React.useEffect(() => { if (!newlyCreatedThreadInfo) { return; } const waitingOnThreadID = waitingOnThreadIDRef.current; if (waitingOnThreadID === null || waitingOnThreadID === undefined) { return; } waitingOnThreadIDRef.current = undefined; pushNewThread(newlyCreatedThreadInfo); }, [newlyCreatedThreadInfo, pushNewThread]); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const { community } = parentThreadInfo; const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const userSearchResults = React.useMemo( () => getPotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, searchIndex: userSearchIndex, excludeUserIDs: userInfoInputIDs, inputParentThreadInfo: parentThreadInfo, inputCommunityThreadInfo: communityThreadInfo, threadType, }), [ usernameInputText, otherUserInfos, userSearchIndex, userInfoInputIDs, parentThreadInfo, communityThreadInfo, threadType, ], ); const existingThreads = React.useMemo(() => { if (userInfoInputIDs.length === 0) { return []; } return _flow( _filter( (threadInfo: ThreadInfo) => threadInFilterList(threadInfo) && threadInfo.parentThreadID === parentThreadInfo.id && userInfoInputIDs.every(userID => userIsMember(threadInfo, userID)), ), _sortBy( ([ 'members.length', (threadInfo: ThreadInfo) => (threadInfo.name ? 1 : 0), ]: $ReadOnlyArray mixed)>), ), )(threadInfos); }, [userInfoInputIDs, threadInfos, parentThreadInfo]); const navigateToThread = useNavigateToThread(); const onSelectExistingThread = React.useCallback( (threadID: string) => { const threadInfo = threadInfos[threadID]; navigateToThread({ threadInfo }); }, [threadInfos, navigateToThread], ); const onUserSelect = React.useCallback( ({ id }: AccountUserInfo) => { if (userInfoInputIDs.some(existingUserID => id === existingUserID)) { return; } setUserInfoInputArray(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[id], ]); setUsernameInputText(''); }, [userInfoInputIDs, otherUserInfos], ); const styles = useStyles(unboundStyles); let existingThreadsSection = null; if (existingThreads.length > 0) { existingThreadsSection = ( Existing channels ); } const inputProps = React.useMemo( () => ({ ...tagInputProps, onSubmitEditing: onPressCreateThread, }), [onPressCreateThread], ); const userSearchResultWithENSNames = useENSNames(userSearchResults); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); return ( To: {existingThreadsSection} ); } const unboundStyles = { container: { flex: 1, }, existingThreadList: { backgroundColor: 'modalBackground', flex: 1, paddingRight: 12, }, existingThreads: { flex: 1, }, existingThreadsLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, textAlign: 'center', }, existingThreadsRow: { backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', borderTopWidth: 1, paddingVertical: 6, }, listItem: { color: 'modalForegroundLabel', }, tagInputContainer: { flex: 1, marginLeft: 8, paddingRight: 12, }, tagInputLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, }, userList: { backgroundColor: 'modalBackground', flex: 1, paddingLeft: 35, paddingRight: 12, }, userSelectionRow: { alignItems: 'center', backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, }; const MemoizedComposeSubchannel: React.ComponentType = React.memo(ComposeSubchannel); export default MemoizedComposeSubchannel; diff --git a/native/chat/inline-engagement.react.js b/native/chat/inline-engagement.react.js index 5030334ce..c5c2b8e90 100644 --- a/native/chat/inline-engagement.react.js +++ b/native/chat/inline-engagement.react.js @@ -1,568 +1,568 @@ // @flow import { useNavigation } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import Animated, { Extrapolate, interpolateNode, } from 'react-native-reanimated'; import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { getInlineEngagementSidebarText } from 'lib/shared/inline-engagement-utils.js'; import { useNextLocalID } from 'lib/shared/message-utils.js'; import type { MessageInfo } from 'lib/types/message-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { inlineEngagementLabelStyle, inlineEngagementStyle, inlineEngagementCenterStyle, inlineEngagementRightStyle, composedMessageStyle, avatarOffset, } from './chat-constants.js'; import { useNavigateToThread } from './message-list-types.js'; import { useSendReaction } from './reaction-message-utils.js'; import CommIcon from '../components/comm-icon.react.js'; import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react.js'; import { MessageReactionsModalRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; function dummyNodeForInlineEngagementHeightMeasurement( sidebarInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, reactions: ReactionInfo, ): React.Element { return ( ); } type DummyInlineEngagementNodeProps = { ...React.ElementConfig, +editedLabel?: ?string, +sidebarInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, +reactions: ReactionInfo, }; function DummyInlineEngagementNode( props: DummyInlineEngagementNodeProps, ): React.Node { const { editedLabel, sidebarInfo, reactions, ...rest } = props; const dummyEditedLabel = React.useMemo(() => { if (!editedLabel) { return null; } return ( {editedLabel} ); }, [editedLabel]); const dummySidebarItem = React.useMemo(() => { if (!sidebarInfo) { return null; } const repliesText = getInlineEngagementSidebarText(sidebarInfo); return ( {repliesText} ); }, [sidebarInfo]); const dummyReactionsList = React.useMemo(() => { if (Object.keys(reactions).length === 0) { return null; } return Object.keys(reactions).map(reaction => { const numOfReacts = reactions[reaction].users.length; return ( {`${reaction} ${numOfReacts}`} ); }); }, [reactions]); const dummyContainerStyle = React.useMemo( () => [unboundStyles.inlineEngagement, unboundStyles.dummyInlineEngagement], [], ); if (!dummyEditedLabel && !dummySidebarItem && !dummyReactionsList) { return null; } return ( {dummyEditedLabel} {dummySidebarItem} {dummyReactionsList} ); } type Props = { +messageInfo: MessageInfo, - +threadInfo: ThreadInfo, - +sidebarThreadInfo: ?ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +sidebarThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, +reactions: ReactionInfo, +disabled?: boolean, +positioning?: 'left' | 'right' | 'center', +label?: ?string, }; function InlineEngagement(props: Props): React.Node { const { messageInfo, threadInfo, sidebarThreadInfo, reactions, disabled = false, positioning, label, } = props; const isLeft = positioning === 'left'; const isRight = positioning === 'right'; const isCenter = positioning === 'center'; const navigateToThread = useNavigateToThread(); const { navigate } = useNavigation(); const styles = useStyles(unboundStyles); const editedLabelStyle = React.useMemo(() => { const stylesResult = [styles.messageLabel, styles.messageLabelColor]; if (isLeft) { stylesResult.push(styles.messageLabelLeft); } else { stylesResult.push(styles.messageLabelRight); } return stylesResult; }, [ isLeft, styles.messageLabel, styles.messageLabelColor, styles.messageLabelLeft, styles.messageLabelRight, ]); const editedLabel = React.useMemo(() => { if (!label) { return null; } return ( {label} ); }, [editedLabelStyle, label]); const unreadStyle = sidebarThreadInfo?.currentUser.unread ? styles.unread : null; const repliesStyles = React.useMemo( () => [styles.repliesText, styles.repliesTextColor, unreadStyle], [styles.repliesText, styles.repliesTextColor, unreadStyle], ); const onPressSidebar = React.useCallback(() => { if (sidebarThreadInfo && !disabled) { navigateToThread({ threadInfo: sidebarThreadInfo }); } }, [disabled, navigateToThread, sidebarThreadInfo]); const repliesText = getInlineEngagementSidebarText(sidebarThreadInfo); const sidebarStyle = React.useMemo(() => { const stylesResult = [styles.sidebar, styles.sidebarColor]; if (Object.keys(reactions).length === 0) { return stylesResult; } if (isRight) { stylesResult.push(styles.sidebarMarginLeft); } else { stylesResult.push(styles.sidebarMarginRight); } return stylesResult; }, [ isRight, reactions, styles.sidebar, styles.sidebarColor, styles.sidebarMarginLeft, styles.sidebarMarginRight, ]); const sidebarItem = React.useMemo(() => { if (!sidebarThreadInfo) { return null; } return ( {repliesText} ); }, [ sidebarThreadInfo, onPressSidebar, sidebarStyle, styles.icon, repliesStyles, repliesText, ]); const localID = useNextLocalID(); const sendReaction = useSendReaction( messageInfo.id, localID, threadInfo.id, reactions, ); const onPressReaction = React.useCallback( (reaction: string) => sendReaction(reaction), [sendReaction], ); const onLongPressReaction = React.useCallback(() => { navigate<'MessageReactionsModal'>({ name: MessageReactionsModalRouteName, params: { reactions }, }); }, [navigate, reactions]); const reactionStyle = React.useMemo(() => { const stylesResult = [ styles.reactionsContainer, styles.reactionsContainerColor, ]; if (isRight) { stylesResult.push(styles.reactionsContainerMarginLeft); } else { stylesResult.push(styles.reactionsContainerMarginRight); } return stylesResult; }, [ isRight, styles.reactionsContainer, styles.reactionsContainerColor, styles.reactionsContainerMarginLeft, styles.reactionsContainerMarginRight, ]); const reactionList = React.useMemo(() => { if (Object.keys(reactions).length === 0) { return null; } return Object.keys(reactions).map(reaction => { const reactionInfo = reactions[reaction]; const numOfReacts = reactionInfo.users.length; const style = reactionInfo.viewerReacted ? [...reactionStyle, styles.reactionsContainerSelected] : reactionStyle; return ( onPressReaction(reaction)} onLongPress={onLongPressReaction} activeOpacity={0.7} key={reaction} > {`${reaction} ${numOfReacts}`} ); }); }, [ onLongPressReaction, onPressReaction, reactionStyle, reactions, styles.reaction, styles.reactionColor, styles.reactionsContainerSelected, ]); const inlineEngagementPositionStyle = React.useMemo(() => { const styleResult = [styles.inlineEngagement]; if (isRight) { styleResult.push(styles.rightInlineEngagement); } else if (isCenter) { styleResult.push(styles.centerInlineEngagement); } return styleResult; }, [ isCenter, isRight, styles.centerInlineEngagement, styles.inlineEngagement, styles.rightInlineEngagement, ]); return ( {editedLabel} {sidebarItem} {reactionList} ); } const unboundStyles = { inlineEngagement: { flexDirection: 'row', marginBottom: inlineEngagementStyle.marginBottom, marginLeft: avatarOffset, flexWrap: 'wrap', top: inlineEngagementStyle.topOffset, }, dummyInlineEngagement: { marginRight: 8, }, centerInlineEngagement: { marginLeft: 20, marginRight: 20, justifyContent: 'center', }, rightInlineEngagement: { flexDirection: 'row-reverse', marginLeft: inlineEngagementRightStyle.marginLeft, }, sidebar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 8, marginTop: inlineEngagementStyle.marginTop, }, dummySidebar: { paddingRight: 8, // 14 (icon) + 4 (marginRight of icon) + 8 (original left padding) paddingLeft: 26, marginRight: 4, }, sidebarColor: { backgroundColor: 'inlineEngagementBackground', }, sidebarMarginLeft: { marginLeft: 4, }, sidebarMarginRight: { marginRight: 4, }, icon: { color: 'inlineEngagementLabel', marginRight: 4, }, repliesText: { fontSize: 14, lineHeight: 22, }, repliesTextColor: { color: 'inlineEngagementLabel', }, unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, reactionsContainer: { display: 'flex', flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 8, marginTop: inlineEngagementStyle.marginTop, }, dummyReactionContainer: { marginRight: 4, }, reactionsContainerColor: { backgroundColor: 'inlineEngagementBackground', }, reactionsContainerSelected: { borderWidth: 1, borderColor: 'inlineEngagementLabel', paddingHorizontal: 7, paddingVertical: 3, }, reactionsContainerMarginLeft: { marginLeft: 4, }, reactionsContainerMarginRight: { marginRight: 4, }, reaction: { fontSize: 14, lineHeight: 22, }, reactionColor: { color: 'inlineEngagementLabel', }, messageLabel: { paddingHorizontal: 3, fontSize: 13, top: inlineEngagementLabelStyle.topOffset, height: inlineEngagementLabelStyle.height, marginTop: inlineEngagementStyle.marginTop, }, dummyMessageLabel: { marginLeft: 9, marginRight: 4, }, messageLabelColor: { color: 'messageLabel', }, messageLabelLeft: { marginLeft: 9, marginRight: 4, }, messageLabelRight: { marginRight: 10, marginLeft: 4, }, avatarOffset: { width: avatarOffset, }, }; type TooltipInlineEngagementProps = { +item: ChatMessageInfoItemWithHeight, +isOpeningSidebar: boolean, +progress: Animated.Node, +windowWidth: number, +positioning: 'left' | 'right' | 'center', +initialCoordinates: { +x: number, +y: number, +width: number, +height: number, }, }; function TooltipInlineEngagement( props: TooltipInlineEngagementProps, ): React.Node { const { item, isOpeningSidebar, progress, windowWidth, initialCoordinates, positioning, } = props; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return const inlineEngagementStyles = React.useMemo(() => { if (positioning === 'left') { return { position: 'absolute', top: inlineEngagementStyle.marginTop + inlineEngagementStyle.topOffset, left: composedMessageStyle.marginLeft, }; } else if (positioning === 'right') { return { position: 'absolute', right: inlineEngagementRightStyle.marginLeft + composedMessageStyle.marginRight, top: inlineEngagementStyle.marginTop + inlineEngagementStyle.topOffset, }; } else if (positioning === 'center') { return { alignSelf: 'center', top: inlineEngagementCenterStyle.topOffset, }; } invariant( false, `${positioning} is not a valid positioning value for InlineEngagement`, ); }, [positioning]); const inlineEngagementContainer = React.useMemo(() => { const opacity = isOpeningSidebar ? 0 : interpolateNode(progress, { inputRange: [0, 1], outputRange: [1, 0], extrapolate: Extrapolate.CLAMP, }); return { position: 'absolute', width: windowWidth, top: initialCoordinates.height, left: -initialCoordinates.x, opacity, }; }, [ initialCoordinates.height, initialCoordinates.x, isOpeningSidebar, progress, windowWidth, ]); return ( ); } export { InlineEngagement, TooltipInlineEngagement, DummyInlineEngagementNode, dummyNodeForInlineEngagementHeightMeasurement, }; diff --git a/native/chat/message-list-container.react.js b/native/chat/message-list-container.react.js index 2aa5a2ade..5d7d0e2f1 100644 --- a/native/chat/message-list-container.react.js +++ b/native/chat/message-list-container.react.js @@ -1,446 +1,447 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome5.js'; import { useNavigationState } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { View, Text } from 'react-native'; import genesis from 'lib/facts/genesis.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors.js'; import { getPotentialMemberItems, useSearchUsers, } from 'lib/shared/search-utils.js'; import { useExistingThreadInfoFinder, pendingThreadType, } from 'lib/shared/thread-utils.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js'; import { type MessagesMeasurer, useHeightMeasurer } from './chat-context.js'; import { ChatInputBar } from './chat-input-bar.react.js'; import type { ChatNavigationProp } from './chat.react.js'; import { type NativeChatMessageItem, useNativeMessageListData, } from './message-data.react.js'; import MessageListThreadSearch from './message-list-thread-search.react.js'; import { MessageListContextProvider } from './message-list-types.js'; import MessageList from './message-list.react.js'; import ParentThreadHeader from './parent-thread-header.react.js'; import ContentLoading from '../components/content-loading.react.js'; import { InputStateContext } from '../input/input-state.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { ThreadSettingsRouteName, MessageResultsScreenRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; type BaseProps = { +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; type Props = { ...BaseProps, // Redux state +usernameInputText: string, +updateUsernameInput: (text: string) => void, +userInfoInputArray: $ReadOnlyArray, +updateTagInput: (items: $ReadOnlyArray) => void, +resolveToUser: (user: AccountUserInfo) => void, +userSearchResults: $ReadOnlyArray, - +threadInfo: ThreadInfo, - +genesisThreadInfo: ?ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + +genesisThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, +messageListData: ?$ReadOnlyArray, +colors: Colors, +styles: typeof unboundStyles, // withOverlayContext +overlayContext: ?OverlayContextType, +measureMessages: MessagesMeasurer, }; type State = { +listDataWithHeights: ?$ReadOnlyArray, }; class MessageListContainer extends React.PureComponent { state: State = { listDataWithHeights: null, }; pendingListDataWithHeights: ?$ReadOnlyArray; get frozen() { const { overlayContext } = this.props; invariant( overlayContext, 'MessageListContainer should have OverlayContext', ); return overlayContext.scrollBlockingModalStatus !== 'closed'; } setListData = ( listDataWithHeights: $ReadOnlyArray, ) => { this.setState({ listDataWithHeights }); }; componentDidMount() { this.props.measureMessages( this.props.messageListData, this.props.threadInfo, this.setListData, ); } componentDidUpdate(prevProps: Props) { const oldListData = prevProps.messageListData; const newListData = this.props.messageListData; if (!newListData && oldListData) { this.setState({ listDataWithHeights: null }); } if ( oldListData !== newListData || prevProps.threadInfo !== this.props.threadInfo || prevProps.measureMessages !== this.props.measureMessages ) { this.props.measureMessages( newListData, this.props.threadInfo, this.allHeightsMeasured, ); } if (!this.frozen && this.pendingListDataWithHeights) { this.setState({ listDataWithHeights: this.pendingListDataWithHeights }); this.pendingListDataWithHeights = undefined; } } render() { const { threadInfo, styles } = this.props; const { listDataWithHeights } = this.state; const { searching } = this.props.route.params; let searchComponent = null; if (searching) { const { userInfoInputArray, genesisThreadInfo } = this.props; // It's technically possible for the client to be missing the Genesis // ThreadInfo when it first opens up (before the server delivers it) let parentThreadHeader; if (genesisThreadInfo) { parentThreadHeader = ( ); } searchComponent = ( <> {parentThreadHeader} ); } const showMessageList = !searching || this.props.userInfoInputArray.length > 0; let messageList; if (showMessageList && listDataWithHeights) { messageList = ( ); } else if (showMessageList) { messageList = ( ); } const threadContentStyles = showMessageList ? [styles.threadContent] : [styles.hiddenThreadContent]; const pointerEvents = showMessageList ? 'auto' : 'none'; const threadContent = ( {messageList} ); return ( {searchComponent} {threadContent} ); } allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { if (this.frozen) { this.pendingListDataWithHeights = listDataWithHeights; } else { this.setState({ listDataWithHeights }); } }; } const unboundStyles = { pinnedCountBanner: { backgroundColor: 'panelForeground', height: 30, flexDirection: 'row', textAlign: 'center', justifyContent: 'center', alignItems: 'center', }, pinnedCountText: { color: 'panelBackgroundLabel', marginRight: 5, }, container: { backgroundColor: 'listBackground', flex: 1, }, threadContent: { flex: 1, }, hiddenThreadContent: { height: 0, opacity: 0, }, }; const ConnectedMessageListContainer: React.ComponentType = React.memo(function ConnectedMessageListContainer( props: BaseProps, ) { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const serverSearchResults = useSearchUsers(usernameInputText); const userSearchResults = React.useMemo(() => { return getPotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, searchIndex: userSearchIndex, excludeUserIDs: userInfoInputArray.map(userInfo => userInfo.id), includeServerSearchUsers: serverSearchResults, }); }, [ usernameInputText, otherUserInfos, userSearchIndex, userInfoInputArray, serverSearchResults, ]); const [baseThreadInfo, setBaseThreadInfo] = React.useState( props.route.params.threadInfo, ); const existingThreadInfoFinder = useExistingThreadInfoFinder(baseThreadInfo); const isSearching = !!props.route.params.searching; const threadInfo = React.useMemo( () => existingThreadInfoFinder({ searching: isSearching, userInfoInputArray, }), [existingThreadInfoFinder, isSearching, userInfoInputArray], ); invariant( threadInfo, 'threadInfo must be specified in messageListContainer', ); const inputState = React.useContext(InputStateContext); invariant(inputState, 'inputState should be set in MessageListContainer'); const isFocused = props.navigation.isFocused(); const { setPendingThreadUpdateHandler } = inputState; React.useEffect(() => { if (!isFocused) { return undefined; } setPendingThreadUpdateHandler(threadInfo.id, setBaseThreadInfo); return () => { setPendingThreadUpdateHandler(threadInfo.id, undefined); }; }, [setPendingThreadUpdateHandler, isFocused, threadInfo.id]); const { setParams } = props.navigation; const navigationStack = useNavigationState(state => state.routes); React.useEffect(() => { const topRoute = navigationStack[navigationStack.length - 1]; if (topRoute?.name !== ThreadSettingsRouteName) { return; } setBaseThreadInfo(threadInfo); if (isSearching) { setParams({ searching: false }); } }, [isSearching, navigationStack, setParams, threadInfo]); const hideSearch = React.useCallback(() => { setBaseThreadInfo(threadInfo); setParams({ searching: false }); }, [setParams, threadInfo]); React.useEffect(() => { if (!isSearching) { return undefined; } inputState.registerSendCallback(hideSearch); return () => inputState.unregisterSendCallback(hideSearch); }, [hideSearch, inputState, isSearching]); React.useEffect(() => { setParams({ threadInfo }); }, [setParams, threadInfo]); const updateTagInput = React.useCallback( (input: $ReadOnlyArray) => setUserInfoInputArray(input), [], ); const updateUsernameInput = React.useCallback( (text: string) => setUsernameInputText(text), [], ); const { editInputMessage } = inputState; const resolveToUser = React.useCallback( (user: AccountUserInfo) => { const resolvedThreadInfo = existingThreadInfoFinder({ searching: true, userInfoInputArray: [user], }); invariant( resolvedThreadInfo, 'resolvedThreadInfo must be specified in messageListContainer', ); editInputMessage({ message: '', mode: 'prepend' }); setBaseThreadInfo(resolvedThreadInfo); setParams({ searching: false, threadInfo: resolvedThreadInfo }); }, [existingThreadInfoFinder, editInputMessage, setParams], ); const messageListData = useNativeMessageListData({ searching: isSearching, userInfoInputArray, threadInfo, }); const colors = useColors(); const styles = useStyles(unboundStyles); const overlayContext = React.useContext(OverlayContext); const measureMessages = useHeightMeasurer(); const genesisThreadInfo = useSelector( state => threadInfoSelector(state)[genesis.id], ); let bannerText; if (!threadInfo.pinnedCount || threadInfo.pinnedCount === 0) { bannerText = ''; } else { const messageNoun = threadInfo.pinnedCount === 1 ? 'message' : 'messages'; bannerText = `${threadInfo.pinnedCount} pinned ${messageNoun}`; } const navigateToMessageResults = React.useCallback(() => { props.navigation.navigate<'MessageResultsScreen'>({ name: MessageResultsScreenRouteName, params: { threadInfo, }, key: `MessageResultsScreen${threadInfo.id}`, }); }, [props.navigation, threadInfo]); const pinnedCountBanner = React.useMemo(() => { if (!bannerText) { return null; } return ( {bannerText} ); }, [ navigateToMessageResults, bannerText, styles.pinnedCountBanner, styles.pinnedCountText, colors.panelBackgroundLabel, ]); return ( {pinnedCountBanner} ); }); export default ConnectedMessageListContainer; diff --git a/native/chat/message-list-header-title.react.js b/native/chat/message-list-header-title.react.js index 3b0fb9b65..0694152fd 100644 --- a/native/chat/message-list-header-title.react.js +++ b/native/chat/message-list-header-title.react.js @@ -1,107 +1,108 @@ // @flow import { HeaderTitle, type HeaderTitleInputProps, } from '@react-navigation/elements'; import * as React from 'react'; import { View } from 'react-native'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { firstLine } from 'lib/utils/string-utils.js'; import type { ChatNavigationProp } from './chat.react.js'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; import Button from '../components/button.react.js'; import { ThreadSettingsRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; type BaseProps = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +navigate: $PropertyType, 'navigate'>, +isSearchEmpty: boolean, +areSettingsEnabled: boolean, ...HeaderTitleInputProps, }; type Props = { ...BaseProps, +styles: typeof unboundStyles, +title: string, }; class MessageListHeaderTitle extends React.PureComponent { render() { const { threadInfo, navigate, isSearchEmpty, areSettingsEnabled, styles, title, ...rest } = this.props; let avatar; if (!isSearchEmpty) { avatar = ( ); } return ( ); } onPress = () => { const { threadInfo } = this.props; this.props.navigate<'ThreadSettings'>({ name: ThreadSettingsRouteName, params: { threadInfo }, key: `${ThreadSettingsRouteName}${threadInfo.id}`, }); }; } const unboundStyles = { avatarContainer: { marginRight: 8, }, button: { flex: 1, }, container: { flex: 1, flexDirection: 'row', alignItems: 'center', }, }; const ConnectedMessageListHeaderTitle: React.ComponentType = React.memo(function ConnectedMessageListHeaderTitle( props: BaseProps, ) { const styles = useStyles(unboundStyles); const { uiName } = useResolvedThreadInfo(props.threadInfo); const { isSearchEmpty } = props; const title = isSearchEmpty ? 'New Message' : uiName; return ; }); export default ConnectedMessageListHeaderTitle; diff --git a/native/chat/message-list-types.js b/native/chat/message-list-types.js index db8d4e02b..c5f5ffa0a 100644 --- a/native/chat/message-list-types.js +++ b/native/chat/message-list-types.js @@ -1,136 +1,136 @@ // @flow import { useNavigation, useNavigationState } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { type UserInfo } from 'lib/types/user-types.js'; import { ChatContext } from './chat-context.js'; import type { ChatRouterNavigationAction } from './chat-router.js'; import type { MarkdownRules } from '../markdown/rules.react.js'; import { useTextMessageRulesFunc } from '../markdown/rules.react.js'; import { MessageListRouteName, TextMessageTooltipModalRouteName, } from '../navigation/route-names.js'; export type MessageListParams = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +pendingPersonalThreadUserInfo?: UserInfo, +searching?: boolean, +removeEditMode?: ?RemoveEditMode, }; export type RemoveEditMode = ( action: ChatRouterNavigationAction, ) => 'ignore_action' | 'reduce_action'; export type MessageListContextType = { +getTextMessageMarkdownRules: (useDarkStyle: boolean) => MarkdownRules, }; const MessageListContext: React.Context = React.createContext(); function useMessageListContext( threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, ) { const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const getTextMessageMarkdownRules = useTextMessageRulesFunc( threadInfo, chatMentionCandidates, ); return React.useMemo( () => ({ getTextMessageMarkdownRules, }), [getTextMessageMarkdownRules], ); } type Props = { +children: React.Node, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, }; function MessageListContextProvider(props: Props): React.Node { const context = useMessageListContext(props.threadInfo); return ( {props.children} ); } type NavigateToThreadAction = { +name: typeof MessageListRouteName, +params: MessageListParams, +key: string, }; function createNavigateToThreadAction( params: MessageListParams, ): NavigateToThreadAction { return { name: MessageListRouteName, params, key: `${MessageListRouteName}${params.threadInfo.id}`, }; } function useNavigateToThread(): (params: MessageListParams) => void { const { navigate } = useNavigation(); return React.useCallback( (params: MessageListParams) => { navigate<'MessageList'>(createNavigateToThreadAction(params)); }, [navigate], ); } function useTextMessageMarkdownRules(useDarkStyle: boolean): MarkdownRules { const messageListContext = React.useContext(MessageListContext); invariant(messageListContext, 'DummyTextNode should have MessageListContext'); return messageListContext.getTextMessageMarkdownRules(useDarkStyle); } function useNavigateToThreadWithFadeAnimation( threadInfo: ThreadInfo, messageKey: ?string, ): () => mixed { const chatContext = React.useContext(ChatContext); invariant(chatContext, 'ChatContext should be set'); const { setCurrentTransitionSidebarSourceID: setSidebarSourceID, setSidebarAnimationType, } = chatContext; const navigateToThread = useNavigateToThread(); const navigationStack = useNavigationState(state => state.routes); return React.useCallback(() => { if ( navigationStack[navigationStack.length - 1].name === TextMessageTooltipModalRouteName ) { setSidebarSourceID(messageKey); setSidebarAnimationType('fade_source_message'); } navigateToThread({ threadInfo }); }, [ messageKey, navigateToThread, navigationStack, setSidebarAnimationType, setSidebarSourceID, threadInfo, ]); } export { MessageListContextProvider, createNavigateToThreadAction, useNavigateToThread, useTextMessageMarkdownRules, useNavigateToThreadWithFadeAnimation, }; diff --git a/native/chat/message-result.react.js b/native/chat/message-result.react.js index f5e5c2bb8..9fd709b03 100644 --- a/native/chat/message-result.react.js +++ b/native/chat/message-result.react.js @@ -1,95 +1,96 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { longAbsoluteDate } from 'lib/utils/date-utils.js'; import { type ChatNavigationProp } from './chat.react.js'; import { MessageListContextProvider } from './message-list-types.js'; import Message from './message.react.js'; import { modifyItemForResultScreen } from './utils.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import type { NavigationRoute } from '../navigation/route-names'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; type MessageResultProps = { +item: ChatMessageInfoItemWithHeight, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +navigation: | AppNavigationProp<'TogglePinModal'> | ChatNavigationProp<'MessageResultsScreen'> | ChatNavigationProp<'MessageSearch'>, +route: | NavigationRoute<'TogglePinModal'> | NavigationRoute<'MessageResultsScreen'> | NavigationRoute<'MessageSearch'>, +messageVerticalBounds: ?VerticalBounds, +scrollable: boolean, }; function MessageResult(props: MessageResultProps): React.Node { const styles = useStyles(unboundStyles); const onToggleFocus = React.useCallback(() => {}, []); const item = React.useMemo( () => modifyItemForResultScreen(props.item), [props.item], ); const containerStyle = React.useMemo( () => props.scrollable ? [styles.container, styles.containerOverflow] : styles.container, [props.scrollable, styles.container, styles.containerOverflow], ); return ( {longAbsoluteDate(props.item.messageInfo.time)} ); } const unboundStyles = { container: { marginTop: 5, backgroundColor: 'panelForeground', }, containerOverflow: { overflow: 'scroll', maxHeight: 400, }, viewContainer: { marginTop: 10, marginBottom: 10, }, messageDate: { color: 'messageLabel', fontSize: 12, marginLeft: 55, }, }; export default MessageResult; diff --git a/native/chat/message-results-screen.react.js b/native/chat/message-results-screen.react.js index e6c2e2ed8..fc5b2e2af 100644 --- a/native/chat/message-results-screen.react.js +++ b/native/chat/message-results-screen.react.js @@ -1,180 +1,181 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { useFetchPinnedMessages } from 'lib/actions/message-actions.js'; import { messageListData } from 'lib/selectors/chat-selectors.js'; import { createMessageInfo, isInvalidPinSourceForThread, } from 'lib/shared/message-utils.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useHeightMeasurer } from './chat-context.js'; import type { ChatNavigationProp } from './chat.react'; import MessageResult from './message-result.react.js'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; export type MessageResultsScreenParams = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, }; type MessageResultsScreenProps = { +navigation: ChatNavigationProp<'MessageResultsScreen'>, +route: NavigationRoute<'MessageResultsScreen'>, }; function MessageResultsScreen(props: MessageResultsScreenProps): React.Node { const { navigation, route } = props; const { threadInfo } = route.params; const styles = useStyles(unboundStyles); const { id: threadID } = threadInfo; const [rawMessageResults, setRawMessageResults] = React.useState([]); const measureMessages = useHeightMeasurer(); const [measuredMessages, setMeasuredMessages] = React.useState([]); const [messageVerticalBounds, setMessageVerticalBounds] = React.useState(); const scrollViewContainerRef = React.useRef(); const callFetchPinnedMessages = useFetchPinnedMessages(); const userInfos = useSelector(state => state.userStore.userInfos); React.useEffect(() => { (async () => { const result = await callFetchPinnedMessages({ threadID }); setRawMessageResults(result.pinnedMessages); })(); }, [callFetchPinnedMessages, threadID]); const translatedMessageResults = React.useMemo(() => { const threadInfos = { [threadID]: threadInfo }; return rawMessageResults .map(messageInfo => createMessageInfo(messageInfo, null, userInfos, threadInfos), ) .filter(Boolean); }, [rawMessageResults, userInfos, threadID, threadInfo]); const chatMessageInfos = useSelector( messageListData(threadInfo.id, translatedMessageResults), ); const sortedUniqueChatMessageInfoItems = React.useMemo(() => { if (!chatMessageInfos) { return []; } const chatMessageInfoItems = chatMessageInfos.filter( item => item.itemType === 'message' && item.isPinned && !isInvalidPinSourceForThread(item.messageInfo, threadInfo), ); // By the nature of using messageListData and passing in // the desired translatedMessageResults as additional // messages, we will have duplicate ChatMessageInfoItems. const uniqueChatMessageInfoItemsMap = new Map(); chatMessageInfoItems.forEach( item => item.messageInfo && item.messageInfo.id && uniqueChatMessageInfoItemsMap.set(item.messageInfo.id, item), ); // Push the items in the order they appear in the rawMessageResults // since the messages fetched from the server are already sorted // in the order of pin_time (newest first). const sortedChatMessageInfoItems = []; for (let i = 0; i < rawMessageResults.length; i++) { sortedChatMessageInfoItems.push( uniqueChatMessageInfoItemsMap.get(rawMessageResults[i].id), ); } return sortedChatMessageInfoItems.filter(Boolean); }, [chatMessageInfos, rawMessageResults, threadInfo]); const measureCallback = React.useCallback( (listDataWithHeights: $ReadOnlyArray) => { setMeasuredMessages(listDataWithHeights); }, [], ); React.useEffect(() => { measureMessages( sortedUniqueChatMessageInfoItems, threadInfo, measureCallback, ); }, [ measureCallback, measureMessages, sortedUniqueChatMessageInfoItems, threadInfo, ]); const onLayout = React.useCallback(() => { scrollViewContainerRef.current?.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } setMessageVerticalBounds({ height, y: pageY }); }, ); }, []); const messageResultsToDisplay = React.useMemo( () => measuredMessages.map(item => { invariant(item.itemType !== 'loader', 'should not be loader'); return ( ); }), [measuredMessages, threadInfo, navigation, route, messageVerticalBounds], ); return ( {messageResultsToDisplay} ); } const unboundStyles = { scrollViewContainer: { flex: 1, }, }; export default MessageResultsScreen; diff --git a/native/chat/parent-thread-header.react.js b/native/chat/parent-thread-header.react.js index 731db9bbe..c903fb362 100644 --- a/native/chat/parent-thread-header.react.js +++ b/native/chat/parent-thread-header.react.js @@ -1,72 +1,73 @@ // @flow import * as React from 'react'; import { View, Text } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadType } from 'lib/types/thread-types-enum.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useNavigateToThread } from './message-list-types.js'; import Button from '../components/button.react.js'; import CommunityPill from '../components/community-pill.react.js'; import ThreadVisibility from '../components/thread-visibility.react.js'; import { useColors, useStyles } from '../themes/colors.js'; type Props = { - +parentThreadInfo: ThreadInfo, + +parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +childThreadType: ThreadType, }; function ParentThreadHeader(props: Props): React.Node { const colors = useColors(); const threadVisibilityColor = colors.modalForegroundLabel; const styles = useStyles(unboundStyles); const { parentThreadInfo, childThreadType } = props; const navigateToThread = useNavigateToThread(); const onPressParentThread = React.useCallback(() => { navigateToThread({ threadInfo: parentThreadInfo }); }, [parentThreadInfo, navigateToThread]); return ( within ); } const height = 48; const unboundStyles = { container: { height, backgroundColor: 'panelForeground', borderBottomWidth: 1, borderBottomColor: 'panelForegroundBorder', }, contentContainer: { alignItems: 'center', flexDirection: 'row', paddingHorizontal: 12, }, within: { color: 'modalSubtextLabel', fontSize: 16, paddingHorizontal: 6, }, }; export default ParentThreadHeader; diff --git a/native/chat/settings/compose-subchannel-modal.react.js b/native/chat/settings/compose-subchannel-modal.react.js index 040f96216..6ab07a3ae 100644 --- a/native/chat/settings/compose-subchannel-modal.react.js +++ b/native/chat/settings/compose-subchannel-modal.react.js @@ -1,152 +1,153 @@ // @flow import IonIcon from '@expo/vector-icons/Ionicons.js'; import * as React from 'react'; import { Text } from 'react-native'; import { threadTypeDescriptions } from 'lib/shared/thread-utils.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import Button from '../../components/button.react.js'; import Modal from '../../components/modal.react.js'; import SWMansionIcon from '../../components/swmansion-icon.react.js'; import type { RootNavigationProp } from '../../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { ComposeSubchannelRouteName } from '../../navigation/route-names.js'; import { type Colors, useStyles, useColors } from '../../themes/colors.js'; export type ComposeSubchannelModalParams = { +presentedFrom: string, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, }; type BaseProps = { +navigation: RootNavigationProp<'ComposeSubchannelModal'>, +route: NavigationRoute<'ComposeSubchannelModal'>, }; type Props = { ...BaseProps, +colors: Colors, +styles: typeof unboundStyles, }; class ComposeSubchannelModal extends React.PureComponent { render() { return ( Chat type ); } onPressOpen = () => { const threadInfo = this.props.route.params.threadInfo; this.props.navigation.navigate<'ComposeSubchannel'>({ name: ComposeSubchannelRouteName, params: { threadType: threadTypes.COMMUNITY_OPEN_SUBTHREAD, parentThreadInfo: threadInfo, }, key: `${ComposeSubchannelRouteName}|` + `${threadInfo.id}|${threadTypes.COMMUNITY_OPEN_SUBTHREAD}`, }); }; onPressSecret = () => { const threadInfo = this.props.route.params.threadInfo; this.props.navigation.navigate<'ComposeSubchannel'>({ name: ComposeSubchannelRouteName, params: { threadType: threadTypes.COMMUNITY_SECRET_SUBTHREAD, parentThreadInfo: threadInfo, }, key: `${ComposeSubchannelRouteName}|` + `${threadInfo.id}|${threadTypes.COMMUNITY_SECRET_SUBTHREAD}`, }); }; } const unboundStyles = { forwardIcon: { color: 'modalForegroundSecondaryLabel', paddingLeft: 10, }, modal: { flex: 0, }, option: { alignItems: 'center', flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 20, }, optionExplanation: { color: 'modalBackgroundLabel', flex: 1, fontSize: 14, paddingLeft: 20, textAlign: 'center', }, optionText: { color: 'modalBackgroundLabel', fontSize: 20, paddingLeft: 5, }, visibility: { color: 'modalBackgroundLabel', fontSize: 24, textAlign: 'center', }, visibilityIcon: { color: 'modalBackgroundLabel', paddingRight: 3, }, }; const ConnectedComposeSubchannelModal: React.ComponentType = React.memo(function ConnectedComposeSubchannelModal( props: BaseProps, ) { const styles = useStyles(unboundStyles); const colors = useColors(); return ( ); }); export default ConnectedComposeSubchannelModal; diff --git a/native/chat/settings/thread-settings-child-thread.react.js b/native/chat/settings/thread-settings-child-thread.react.js index 706e75aeb..73029bd2d 100644 --- a/native/chat/settings/thread-settings-child-thread.react.js +++ b/native/chat/settings/thread-settings-child-thread.react.js @@ -1,83 +1,84 @@ // @flow import * as React from 'react'; import { View, Platform } from 'react-native'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import ThreadAvatar from '../../avatars/thread-avatar.react.js'; import Button from '../../components/button.react.js'; import ThreadIcon from '../../components/thread-icon.react.js'; import ThreadPill from '../../components/thread-pill.react.js'; import { useColors, useStyles } from '../../themes/colors.js'; import { useNavigateToThread } from '../message-list-types.js'; type Props = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +firstListItem: boolean, +lastListItem: boolean, }; function ThreadSettingsChildThread(props: Props): React.Node { const { threadInfo } = props; const navigateToThread = useNavigateToThread(); const onPress = React.useCallback(() => { navigateToThread({ threadInfo }); }, [threadInfo, navigateToThread]); const styles = useStyles(unboundStyles); const colors = useColors(); const firstItem = props.firstListItem ? null : styles.topBorder; const lastItem = props.lastListItem ? styles.lastButton : null; return ( ); } const unboundStyles = { avatarContainer: { marginRight: 8, }, button: { flex: 1, flexDirection: 'row', paddingVertical: 8, paddingLeft: 12, paddingRight: 10, alignItems: 'center', }, topBorder: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, container: { backgroundColor: 'panelForeground', flex: 1, paddingHorizontal: 12, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 12 : 10, paddingTop: 8, }, leftSide: { flex: 1, flexDirection: 'row', alignItems: 'center', }, }; export default ThreadSettingsChildThread; diff --git a/native/chat/settings/thread-settings-member-tooltip-modal.react.js b/native/chat/settings/thread-settings-member-tooltip-modal.react.js index 5ab148c22..07da4909e 100644 --- a/native/chat/settings/thread-settings-member-tooltip-modal.react.js +++ b/native/chat/settings/thread-settings-member-tooltip-modal.react.js @@ -1,111 +1,115 @@ // @flow import * as React from 'react'; import { useRemoveUsersFromThread } from 'lib/actions/thread-actions.js'; import { removeMemberFromThread } from 'lib/shared/thread-utils.js'; import { stringForUser } from 'lib/shared/user-utils.js'; +import type { + MinimallyEncodedRelativeMemberInfo, + MinimallyEncodedThreadInfo, +} from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo, RelativeMemberInfo } from 'lib/types/thread-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import ThreadSettingsMemberTooltipButton from './thread-settings-member-tooltip-button.react.js'; import type { AppNavigationProp } from '../../navigation/app-navigator.react'; import { ChangeRolesScreenRouteName } from '../../navigation/route-names.js'; import { createTooltip, type TooltipParams, type TooltipRoute, type BaseTooltipProps, type TooltipMenuProps, } from '../../tooltip/tooltip.react.js'; import Alert from '../../utils/alert.js'; export type ThreadSettingsMemberTooltipModalParams = TooltipParams<{ - +memberInfo: RelativeMemberInfo, - +threadInfo: ThreadInfo, + +memberInfo: RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, }>; function useOnRemoveUser( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, ) { const { memberInfo, threadInfo } = route.params; const boundRemoveUsersFromThread = useRemoveUsersFromThread(); const dispatchActionPromise = useDispatchActionPromise(); const onConfirmRemoveUser = React.useCallback( () => removeMemberFromThread( threadInfo, memberInfo, dispatchActionPromise, boundRemoveUsersFromThread, ), [threadInfo, memberInfo, dispatchActionPromise, boundRemoveUsersFromThread], ); const userText = stringForUser(memberInfo); return React.useCallback(() => { Alert.alert( 'Confirm removal', `Are you sure you want to remove ${userText} from this chat?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmRemoveUser }, ], { cancelable: true }, ); }, [onConfirmRemoveUser, userText]); } function useOnChangeRole( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, navigation: AppNavigationProp<'ThreadSettingsMemberTooltipModal'>, ) { const { threadInfo, memberInfo } = route.params; return React.useCallback(() => { navigation.navigate<'ChangeRolesScreen'>({ name: ChangeRolesScreenRouteName, params: { threadInfo, memberInfo, role: memberInfo.role, }, key: route.key, }); }, [navigation, route.key, threadInfo, memberInfo]); } function TooltipMenu( props: TooltipMenuProps<'ThreadSettingsMemberTooltipModal'>, ): React.Node { const { route, navigation, tooltipItem: TooltipItem } = props; const onChangeRole = useOnChangeRole(route, navigation); const onRemoveUser = useOnRemoveUser(route); return ( <> ); } const ThreadSettingsMemberTooltipModal: React.ComponentType< BaseTooltipProps<'ThreadSettingsMemberTooltipModal'>, > = createTooltip<'ThreadSettingsMemberTooltipModal'>( ThreadSettingsMemberTooltipButton, TooltipMenu, ); export default ThreadSettingsMemberTooltipModal; diff --git a/native/chat/settings/thread-settings-member.react.js b/native/chat/settings/thread-settings-member.react.js index 674e313fe..11c3de21f 100644 --- a/native/chat/settings/thread-settings-member.react.js +++ b/native/chat/settings/thread-settings-member.react.js @@ -1,302 +1,306 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, Platform, ActivityIndicator, TouchableOpacity, } from 'react-native'; import { removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { getAvailableThreadMemberActions } from 'lib/shared/thread-utils.js'; import { stringForUser } from 'lib/shared/user-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; +import type { + MinimallyEncodedRelativeMemberInfo, + MinimallyEncodedThreadInfo, +} from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ThreadInfo, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import { useRolesFromCommunityThreadInfo } from 'lib/utils/role-utils.js'; import type { ThreadSettingsNavigate } from './thread-settings.react.js'; import UserAvatar from '../../avatars/user-avatar.react.js'; import PencilIcon from '../../components/pencil-icon.react.js'; import SingleLine from '../../components/single-line.react.js'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state.js'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context.js'; import { ThreadSettingsMemberTooltipModalRouteName } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import type { VerticalBounds } from '../../types/layout-types.js'; import { useNavigateToUserProfileBottomSheet } from '../../user-profile/user-profile-utils.js'; type BaseProps = { - +memberInfo: RelativeMemberInfo, - +threadInfo: ThreadInfo, + +memberInfo: RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, }; type Props = { ...BaseProps, // Redux state +roleName: ?string, +removeUserLoadingStatus: LoadingStatus, +changeRoleLoadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, +navigateToUserProfileBottomSheet: (userID: string) => mixed, }; class ThreadSettingsMember extends React.PureComponent { editButton: ?React.ElementRef; render() { const userText = stringForUser(this.props.memberInfo); let usernameInfo = null; if (this.props.memberInfo.username) { usernameInfo = ( {userText} ); } else { usernameInfo = ( {userText} ); } let editButton = null; if ( this.props.removeUserLoadingStatus === 'loading' || this.props.changeRoleLoadingStatus === 'loading' ) { editButton = ( ); } else if ( getAvailableThreadMemberActions( this.props.memberInfo, this.props.threadInfo, this.props.canEdit, ).length !== 0 ) { editButton = ( ); } const roleInfo = ( {this.props.roleName} ); const firstItem = this.props.firstListItem ? null : this.props.styles.topBorder; const lastItem = this.props.lastListItem ? this.props.styles.lastContainer : null; return ( {usernameInfo} {editButton} {roleInfo} ); } onPressUser = () => { this.props.navigateToUserProfileBottomSheet(this.props.memberInfo.id); }; editButtonRef = (editButton: ?React.ElementRef) => { this.editButton = editButton; }; onEditButtonLayout = () => {}; onPressEdit = () => { if (this.dismissKeyboardIfShowing()) { return; } const { editButton, props: { verticalBounds }, } = this; if (!editButton || !verticalBounds) { return; } const { overlayContext } = this.props; invariant( overlayContext, 'ThreadSettingsMember should have OverlayContext', ); overlayContext.setScrollBlockingModalStatus('open'); editButton.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigate<'ThreadSettingsMemberTooltipModal'>({ name: ThreadSettingsMemberTooltipModalRouteName, params: { presentedFrom: this.props.threadSettingsRouteKey, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: getAvailableThreadMemberActions( this.props.memberInfo, this.props.threadInfo, this.props.canEdit, ), memberInfo: this.props.memberInfo, threadInfo: this.props.threadInfo, }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const unboundStyles = { container: { backgroundColor: 'panelForeground', flex: 1, paddingHorizontal: 24, paddingVertical: 8, }, editButton: { paddingLeft: 10, }, topBorder: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastContainer: { paddingBottom: Platform.OS === 'ios' ? 12 : 10, }, role: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 14, paddingTop: 4, }, row: { flex: 1, flexDirection: 'row', }, userInfoContainer: { flex: 1, flexDirection: 'row', alignItems: 'center', }, username: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, marginLeft: 8, }, anonymous: { color: 'panelForegroundTertiaryLabel', fontStyle: 'italic', }, }; const ConnectedThreadSettingsMember: React.ComponentType = React.memo(function ConnectedThreadSettingsMember( props: BaseProps, ) { const memberID = props.memberInfo.id; const removeUserLoadingStatus = useSelector(state => createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${memberID}`, )(state), ); const changeRoleLoadingStatus = useSelector(state => createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${memberID}`, )(state), ); const [memberInfo] = useENSNames([props.memberInfo]); const colors = useColors(); const styles = useStyles(unboundStyles); const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const navigateToUserProfileBottomSheet = useNavigateToUserProfileBottomSheet(); const roles = useRolesFromCommunityThreadInfo(props.threadInfo, [ props.memberInfo, ]); const roleName = roles.get(props.memberInfo.id)?.name; return ( ); }); export default ConnectedThreadSettingsMember; diff --git a/native/chat/settings/thread-settings-parent.react.js b/native/chat/settings/thread-settings-parent.react.js index 9fbc9ea2e..4559ed1ca 100644 --- a/native/chat/settings/thread-settings-parent.react.js +++ b/native/chat/settings/thread-settings-parent.react.js @@ -1,116 +1,116 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import ThreadAvatar from '../../avatars/thread-avatar.react.js'; import Button from '../../components/button.react.js'; import ThreadPill from '../../components/thread-pill.react.js'; import { useStyles } from '../../themes/colors.js'; import { useNavigateToThread } from '../message-list-types.js'; type ParentButtonProps = { - +parentThreadInfo: ThreadInfo, + +parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, }; function ParentButton(props: ParentButtonProps): React.Node { const styles = useStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const onPressParentThread = React.useCallback(() => { navigateToThread({ threadInfo: props.parentThreadInfo }); }, [props.parentThreadInfo, navigateToThread]); return ( ); } type ThreadSettingsParentProps = { +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, - +parentThreadInfo: ?ThreadInfo, + +parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, }; function ThreadSettingsParent(props: ThreadSettingsParentProps): React.Node { const { threadInfo, parentThreadInfo } = props; const styles = useStyles(unboundStyles); let parent; if (parentThreadInfo) { parent = ; } else if (threadInfo.parentThreadID) { parent = ( Secret parent ); } else { parent = ( No parent ); } return ( Parent {parent} ); } const unboundStyles = { avatarContainer: { marginRight: 8, }, currentValue: { flex: 1, }, currentValueText: { color: 'panelForegroundSecondaryLabel', fontFamily: 'Arial', fontSize: 16, margin: 0, paddingRight: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, noParent: { fontStyle: 'italic', paddingLeft: 2, }, parentContainer: { flexDirection: 'row', }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 4, alignItems: 'center', }, }; const ConnectedThreadSettingsParent: React.ComponentType = React.memo(ThreadSettingsParent); export default ConnectedThreadSettingsParent; diff --git a/native/chat/settings/thread-settings.react.js b/native/chat/settings/thread-settings.react.js index 46869787b..0e4fa9b0c 100644 --- a/native/chat/settings/thread-settings.react.js +++ b/native/chat/settings/thread-settings.react.js @@ -1,1266 +1,1279 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Platform } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { createSelector } from 'reselect'; import tinycolor from 'tinycolor2'; import { changeThreadSettingsActionTypes, leaveThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions.js'; import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector, childThreadInfos, } from 'lib/selectors/thread-selectors.js'; import { getAvailableRelationshipButtons } from 'lib/shared/relationship-utils.js'; import { threadHasPermission, viewerIsMember, threadInChatList, getSingleOtherUser, threadIsChannel, } from 'lib/shared/thread-utils.js'; import threadWatcher from 'lib/shared/thread-watcher.js'; import type { + MinimallyEncodedRelativeMemberInfo, MinimallyEncodedResolvedThreadInfo, MinimallyEncodedThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { RelationshipButton } from 'lib/types/relationship-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type ThreadInfo, type ResolvedThreadInfo, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import type { UserInfos } from 'lib/types/user-types.js'; import { useResolvedThreadInfo, useResolvedOptionalThreadInfo, useResolvedOptionalThreadInfos, } from 'lib/utils/entity-helpers.js'; import ThreadSettingsAvatar from './thread-settings-avatar.react.js'; import type { CategoryType } from './thread-settings-category.react.js'; import { ThreadSettingsCategoryHeader, ThreadSettingsCategoryActionHeader, ThreadSettingsCategoryFooter, } from './thread-settings-category.react.js'; import ThreadSettingsChildThread from './thread-settings-child-thread.react.js'; import ThreadSettingsColor from './thread-settings-color.react.js'; import ThreadSettingsDeleteThread from './thread-settings-delete-thread.react.js'; import ThreadSettingsDescription from './thread-settings-description.react.js'; import ThreadSettingsEditRelationship from './thread-settings-edit-relationship.react.js'; import ThreadSettingsHomeNotifs from './thread-settings-home-notifs.react.js'; import ThreadSettingsLeaveThread from './thread-settings-leave-thread.react.js'; import { ThreadSettingsSeeMore, ThreadSettingsAddMember, ThreadSettingsAddSubchannel, } from './thread-settings-list-action.react.js'; import ThreadSettingsMediaGallery from './thread-settings-media-gallery.react.js'; import ThreadSettingsMember from './thread-settings-member.react.js'; import ThreadSettingsName from './thread-settings-name.react.js'; import ThreadSettingsParent from './thread-settings-parent.react.js'; import ThreadSettingsPromoteSidebar from './thread-settings-promote-sidebar.react.js'; import ThreadSettingsPushNotifs from './thread-settings-push-notifs.react.js'; import ThreadSettingsVisibility from './thread-settings-visibility.react.js'; import ThreadAncestors from '../../components/thread-ancestors.react.js'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state.js'; import { defaultStackScreenOptions } from '../../navigation/options.js'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context.js'; import { AddUsersModalRouteName, ComposeSubchannelModalRouteName, FullScreenThreadMediaGalleryRouteName, } from '../../navigation/route-names.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import type { TabNavigationProp } from '../../navigation/tab-navigator.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import type { AppState } from '../../redux/state-types.js'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../../themes/colors.js'; import type { VerticalBounds } from '../../types/layout-types.js'; import type { ViewStyle } from '../../types/styles.js'; import type { ChatNavigationProp } from '../chat.react.js'; const itemPageLength = 5; export type ThreadSettingsParams = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, }; export type ThreadSettingsNavigate = $PropertyType< ChatNavigationProp<'ThreadSettings'>, 'navigate', >; type ChatSettingsItem = | { +itemType: 'header', +key: string, +title: string, +categoryType: CategoryType, } | { +itemType: 'actionHeader', +key: string, +title: string, +actionText: string, +onPress: () => void, } | { +itemType: 'footer', +key: string, +categoryType: CategoryType, } | { +itemType: 'avatar', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +canChangeSettings: boolean, } | { +itemType: 'name', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +nameEditValue: ?string, +canChangeSettings: boolean, } | { +itemType: 'color', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +colorEditValue: string, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, } | { +itemType: 'description', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +canChangeSettings: boolean, } | { +itemType: 'parent', +key: string, - +threadInfo: ResolvedThreadInfo, - +parentThreadInfo: ?ResolvedThreadInfo, + +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + +parentThreadInfo: + | ?ResolvedThreadInfo + | ?MinimallyEncodedResolvedThreadInfo, } | { +itemType: 'visibility', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, } | { +itemType: 'pushNotifs', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, } | { +itemType: 'homeNotifs', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, } | { +itemType: 'seeMore', +key: string, +onPress: () => void, } | { +itemType: 'childThread', +key: string, - +threadInfo: ResolvedThreadInfo, + +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +firstListItem: boolean, +lastListItem: boolean, } | { +itemType: 'addSubchannel', +key: string, } | { +itemType: 'member', +key: string, - +memberInfo: RelativeMemberInfo, - +threadInfo: ResolvedThreadInfo, + +memberInfo: RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo, + +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, } | { +itemType: 'addMember', +key: string, } | { +itemType: 'mediaGallery', +key: string, +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +limit: number, +verticalBounds: ?VerticalBounds, } | { +itemType: 'promoteSidebar' | 'leaveThread' | 'deleteThread', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, } | { +itemType: 'editRelationship', +key: string, +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, +relationshipButton: RelationshipButton, }; type BaseProps = { +navigation: ChatNavigationProp<'ThreadSettings'>, +route: NavigationRoute<'ThreadSettings'>, }; type Props = { ...BaseProps, // Redux state +userInfos: UserInfos, +viewerID: ?string, - +threadInfo: ResolvedThreadInfo, - +parentThreadInfo: ?ResolvedThreadInfo, + +threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + +parentThreadInfo: ?ResolvedThreadInfo | ?MinimallyEncodedResolvedThreadInfo, +childThreadInfos: ?$ReadOnlyArray, +somethingIsSaving: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, +canPromoteSidebar: boolean, }; type State = { +numMembersShowing: number, +numSubchannelsShowing: number, +numSidebarsShowing: number, +nameEditValue: ?string, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +colorEditValue: string, +verticalBounds: ?VerticalBounds, }; type PropsAndState = { ...Props, ...State }; class ThreadSettings extends React.PureComponent { flatListContainer: ?React.ElementRef; constructor(props: Props) { super(props); this.state = { numMembersShowing: itemPageLength, numSubchannelsShowing: itemPageLength, numSidebarsShowing: itemPageLength, nameEditValue: null, descriptionEditValue: null, descriptionTextHeight: null, colorEditValue: props.threadInfo.color, verticalBounds: null, }; } static scrollDisabled(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'ThreadSettings should have OverlayContext'); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidUpdate(prevProps: Props) { const prevThreadInfo = prevProps.threadInfo; const newThreadInfo = this.props.threadInfo; if ( !tinycolor.equals(newThreadInfo.color, prevThreadInfo.color) && tinycolor.equals(this.state.colorEditValue, prevThreadInfo.color) ) { this.setState({ colorEditValue: newThreadInfo.color }); } if (defaultStackScreenOptions.gestureEnabled) { const scrollIsDisabled = ThreadSettings.scrollDisabled(this.props); const scrollWasDisabled = ThreadSettings.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } } threadBasicsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.nameEditValue, (propsAndState: PropsAndState) => propsAndState.colorEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionTextHeight, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, ( - threadInfo: ResolvedThreadInfo, - parentThreadInfo: ?ResolvedThreadInfo, + threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + parentThreadInfo: + | ?ResolvedThreadInfo + | ?MinimallyEncodedResolvedThreadInfo, nameEditValue: ?string, colorEditValue: string, descriptionEditValue: ?string, descriptionTextHeight: ?number, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, ) => { const canEditThreadAvatar = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_AVATAR, ); const canEditThreadName = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); const canEditThreadDescription = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const canEditThreadColor = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_COLOR, ); const canChangeAvatar = canEditThreadAvatar && canStartEditing; const canChangeName = canEditThreadName && canStartEditing; const canChangeDescription = canEditThreadDescription && canStartEditing; const canChangeColor = canEditThreadColor && canStartEditing; const listData: ChatSettingsItem[] = []; listData.push({ itemType: 'header', key: 'avatarHeader', title: 'Channel Avatar', categoryType: 'unpadded', }); listData.push({ itemType: 'avatar', key: 'avatar', threadInfo, canChangeSettings: canChangeAvatar, }); listData.push({ itemType: 'footer', key: 'avatarFooter', categoryType: 'outline', }); listData.push({ itemType: 'header', key: 'basicsHeader', title: 'Basics', categoryType: 'full', }); listData.push({ itemType: 'name', key: 'name', threadInfo, nameEditValue, canChangeSettings: canChangeName, }); listData.push({ itemType: 'color', key: 'color', threadInfo, colorEditValue, canChangeSettings: canChangeColor, navigate, threadSettingsRouteKey: routeKey, }); listData.push({ itemType: 'footer', key: 'basicsFooter', categoryType: 'full', }); if ( (descriptionEditValue !== null && descriptionEditValue !== undefined) || threadInfo.description || canEditThreadDescription ) { listData.push({ itemType: 'description', key: 'description', threadInfo, descriptionEditValue, descriptionTextHeight, canChangeSettings: canChangeDescription, }); } const isMember = viewerIsMember(threadInfo); if (isMember) { listData.push({ itemType: 'header', key: 'subscriptionHeader', title: 'Subscription', categoryType: 'full', }); listData.push({ itemType: 'pushNotifs', key: 'pushNotifs', threadInfo, }); if (threadInfo.type !== threadTypes.SIDEBAR) { listData.push({ itemType: 'homeNotifs', key: 'homeNotifs', threadInfo, }); } listData.push({ itemType: 'footer', key: 'subscriptionFooter', categoryType: 'full', }); } listData.push({ itemType: 'header', key: 'privacyHeader', title: 'Privacy', categoryType: 'full', }); listData.push({ itemType: 'visibility', key: 'visibility', threadInfo, }); listData.push({ itemType: 'parent', key: 'parent', threadInfo, parentThreadInfo, }); listData.push({ itemType: 'footer', key: 'privacyFooter', categoryType: 'full', }); return listData; }, ); subchannelsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSubchannelsShowing, ( - threadInfo: ResolvedThreadInfo, + threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, navigate: ThreadSettingsNavigate, - childThreads: ?$ReadOnlyArray, + childThreads: ?$ReadOnlyArray< + ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + >, numSubchannelsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const subchannels = childThreads?.filter(threadIsChannel) ?? []; const canCreateSubchannels = threadHasPermission( threadInfo, threadPermissions.CREATE_SUBCHANNELS, ); if (subchannels.length === 0 && !canCreateSubchannels) { return listData; } listData.push({ itemType: 'header', key: 'subchannelHeader', title: 'Subchannels', categoryType: 'unpadded', }); if (canCreateSubchannels) { listData.push({ itemType: 'addSubchannel', key: 'addSubchannel', }); } const numItems = Math.min(numSubchannelsShowing, subchannels.length); for (let i = 0; i < numItems; i++) { const subchannelInfo = subchannels[i]; listData.push({ itemType: 'childThread', key: `childThread${subchannelInfo.id}`, threadInfo: subchannelInfo, firstListItem: i === 0 && !canCreateSubchannels, lastListItem: i === numItems - 1 && numItems === subchannels.length, }); } if (numItems < subchannels.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSubchannels', onPress: this.onPressSeeMoreSubchannels, }); } listData.push({ itemType: 'footer', key: 'subchannelFooter', categoryType: 'unpadded', }); return listData; }, ); sidebarsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSidebarsShowing, ( navigate: ThreadSettingsNavigate, - childThreads: ?$ReadOnlyArray, + childThreads: ?$ReadOnlyArray< + ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + >, numSidebarsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const sidebars = childThreads?.filter( childThreadInfo => childThreadInfo.type === threadTypes.SIDEBAR, ) ?? []; if (sidebars.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'sidebarHeader', title: 'Threads', categoryType: 'unpadded', }); const numItems = Math.min(numSidebarsShowing, sidebars.length); for (let i = 0; i < numItems; i++) { const sidebarInfo = sidebars[i]; listData.push({ itemType: 'childThread', key: `childThread${sidebarInfo.id}`, threadInfo: sidebarInfo, firstListItem: i === 0, lastListItem: i === numItems - 1 && numItems === sidebars.length, }); } if (numItems < sidebars.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSidebars', onPress: this.onPressSeeMoreSidebars, }); } listData.push({ itemType: 'footer', key: 'sidebarFooter', categoryType: 'unpadded', }); return listData; }, ); threadMembersListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, (propsAndState: PropsAndState) => propsAndState.numMembersShowing, (propsAndState: PropsAndState) => propsAndState.verticalBounds, ( - threadInfo: ResolvedThreadInfo, + threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, numMembersShowing: number, verticalBounds: ?VerticalBounds, ) => { const listData: ChatSettingsItem[] = []; const canAddMembers = threadHasPermission( threadInfo, threadPermissions.ADD_MEMBERS, ); if (threadInfo.members.length === 0 && !canAddMembers) { return listData; } listData.push({ itemType: 'header', key: 'memberHeader', title: 'Members', categoryType: 'unpadded', }); if (canAddMembers) { listData.push({ itemType: 'addMember', key: 'addMember', }); } const numItems = Math.min(numMembersShowing, threadInfo.members.length); for (let i = 0; i < numItems; i++) { const memberInfo = threadInfo.members[i]; listData.push({ itemType: 'member', key: `member${memberInfo.id}`, memberInfo, threadInfo, canEdit: canStartEditing, navigate, firstListItem: i === 0 && !canAddMembers, lastListItem: i === numItems - 1 && numItems === threadInfo.members.length, verticalBounds, threadSettingsRouteKey: routeKey, }); } if (numItems < threadInfo.members.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreMembers', onPress: this.onPressSeeMoreMembers, }); } listData.push({ itemType: 'footer', key: 'memberFooter', categoryType: 'unpadded', }); return listData; }, ); mediaGalleryListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.verticalBounds, ( threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, verticalBounds: ?VerticalBounds, ) => { const listData: ChatSettingsItem[] = []; const limit = 6; listData.push({ itemType: 'actionHeader', key: 'mediaGalleryHeader', title: 'Media Gallery', actionText: 'See more', onPress: this.onPressSeeMoreMediaGallery, }); listData.push({ itemType: 'mediaGallery', key: 'mediaGallery', threadInfo, limit, verticalBounds, }); listData.push({ itemType: 'footer', key: 'mediaGalleryFooter', categoryType: 'outline', }); return listData; }, ); actionsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.styles, (propsAndState: PropsAndState) => propsAndState.userInfos, (propsAndState: PropsAndState) => propsAndState.viewerID, ( - threadInfo: ResolvedThreadInfo, - parentThreadInfo: ?ResolvedThreadInfo, + threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, + parentThreadInfo: + | ?ResolvedThreadInfo + | ?MinimallyEncodedResolvedThreadInfo, navigate: ThreadSettingsNavigate, styles: typeof unboundStyles, userInfos: UserInfos, viewerID: ?string, ) => { const buttons = []; if (this.props.canPromoteSidebar) { buttons.push({ itemType: 'promoteSidebar', key: 'promoteSidebar', threadInfo, navigate, }); } const canLeaveThread = threadHasPermission( threadInfo, threadPermissions.LEAVE_THREAD, ); if (viewerIsMember(threadInfo) && canLeaveThread) { buttons.push({ itemType: 'leaveThread', key: 'leaveThread', threadInfo, navigate, }); } const canDeleteThread = threadHasPermission( threadInfo, threadPermissions.DELETE_THREAD, ); if (canDeleteThread) { buttons.push({ itemType: 'deleteThread', key: 'deleteThread', threadInfo, navigate, }); } const threadIsPersonal = threadInfo.type === threadTypes.PERSONAL; if (threadIsPersonal && viewerID) { const otherMemberID = getSingleOtherUser(threadInfo, viewerID); if (otherMemberID) { const otherUserInfo = userInfos[otherMemberID]; const availableRelationshipActions = getAvailableRelationshipButtons(otherUserInfo); for (const action of availableRelationshipActions) { buttons.push({ itemType: 'editRelationship', key: action, threadInfo, navigate, relationshipButton: action, }); } } } const listData: ChatSettingsItem[] = []; if (buttons.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'actionsHeader', title: 'Actions', categoryType: 'unpadded', }); for (let i = 0; i < buttons.length; i++) { // Necessary for Flow... if (buttons[i].itemType === 'editRelationship') { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } else { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } } listData.push({ itemType: 'footer', key: 'actionsFooter', categoryType: 'unpadded', }); return listData; }, ); listDataSelector = createSelector( this.threadBasicsListDataSelector, this.subchannelsListDataSelector, this.sidebarsListDataSelector, this.threadMembersListDataSelector, this.mediaGalleryListDataSelector, this.actionsListDataSelector, ( threadBasicsListData: ChatSettingsItem[], subchannelsListData: ChatSettingsItem[], sidebarsListData: ChatSettingsItem[], threadMembersListData: ChatSettingsItem[], mediaGalleryListData: ChatSettingsItem[], actionsListData: ChatSettingsItem[], ) => [ ...threadBasicsListData, ...subchannelsListData, ...sidebarsListData, ...threadMembersListData, ...mediaGalleryListData, ...actionsListData, ], ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { return ( ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ verticalBounds: { height, y: pageY } }); }); }; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return renderItem = (row: { item: ChatSettingsItem, ... }) => { const item = row.item; if (item.itemType === 'header') { return ( ); } else if (item.itemType === 'actionHeader') { return ( ); } else if (item.itemType === 'footer') { return ; } else if (item.itemType === 'avatar') { return ( ); } else if (item.itemType === 'name') { return ( ); } else if (item.itemType === 'color') { return ( ); } else if (item.itemType === 'description') { return ( ); } else if (item.itemType === 'parent') { return ( ); } else if (item.itemType === 'visibility') { return ; } else if (item.itemType === 'pushNotifs') { return ; } else if (item.itemType === 'homeNotifs') { return ; } else if (item.itemType === 'seeMore') { return ; } else if (item.itemType === 'childThread') { return ( ); } else if (item.itemType === 'addSubchannel') { return ( ); } else if (item.itemType === 'member') { return ( ); } else if (item.itemType === 'addMember') { return ; } else if (item.itemType === 'mediaGallery') { return ( ); } else if (item.itemType === 'leaveThread') { return ( ); } else if (item.itemType === 'deleteThread') { return ( ); } else if (item.itemType === 'promoteSidebar') { return ( ); } else if (item.itemType === 'editRelationship') { return ( ); } else { invariant(false, `unexpected ThreadSettings item type ${item.itemType}`); } }; setNameEditValue = (value: ?string, callback?: () => void) => { this.setState({ nameEditValue: value }, callback); }; setColorEditValue = (color: string) => { this.setState({ colorEditValue: color }); }; setDescriptionEditValue = (value: ?string, callback?: () => void) => { this.setState({ descriptionEditValue: value }, callback); }; setDescriptionTextHeight = (height: number) => { this.setState({ descriptionTextHeight: height }); }; onPressComposeSubchannel = () => { this.props.navigation.navigate(ComposeSubchannelModalRouteName, { presentedFrom: this.props.route.key, threadInfo: this.props.threadInfo, }); }; onPressAddMember = () => { this.props.navigation.navigate(AddUsersModalRouteName, { presentedFrom: this.props.route.key, threadInfo: this.props.threadInfo, }); }; onPressSeeMoreMembers = () => { this.setState(prevState => ({ numMembersShowing: prevState.numMembersShowing + itemPageLength, })); }; onPressSeeMoreSubchannels = () => { this.setState(prevState => ({ numSubchannelsShowing: prevState.numSubchannelsShowing + itemPageLength, })); }; onPressSeeMoreSidebars = () => { this.setState(prevState => ({ numSidebarsShowing: prevState.numSidebarsShowing + itemPageLength, })); }; onPressSeeMoreMediaGallery = () => { this.props.navigation.navigate(FullScreenThreadMediaGalleryRouteName, { threadInfo: this.props.threadInfo, }); }; } const unboundStyles = { container: { backgroundColor: 'panelBackground', flex: 1, }, flatList: { paddingVertical: 16, }, nonTopButton: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 14 : 12, }, }; const threadMembersChangeIsSaving = ( state: AppState, - threadMembers: $ReadOnlyArray, + threadMembers: $ReadOnlyArray< + RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo, + >, ) => { for (const threadMember of threadMembers) { const removeUserLoadingStatus = createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${threadMember.id}`, )(state); if (removeUserLoadingStatus === 'loading') { return true; } const changeRoleLoadingStatus = createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${threadMember.id}`, )(state); if (changeRoleLoadingStatus === 'loading') { return true; } } return false; }; const ConnectedThreadSettings: React.ComponentType = React.memo(function ConnectedThreadSettings(props: BaseProps) { const userInfos = useSelector(state => state.userStore.userInfos); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const threadID = props.route.params.threadInfo.id; const reduxThreadInfo: ?ThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); React.useEffect(() => { invariant( reduxThreadInfo, 'ReduxThreadInfo should exist when ThreadSettings is opened', ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const { setParams } = props.navigation; React.useEffect(() => { if (reduxThreadInfo) { setParams({ threadInfo: reduxThreadInfo }); } }, [reduxThreadInfo, setParams]); - const threadInfo: ThreadInfo = + const threadInfo: ThreadInfo | MinimallyEncodedThreadInfo = reduxThreadInfo ?? props.route.params.threadInfo; const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); React.useEffect(() => { if (threadInChatList(threadInfo)) { return undefined; } threadWatcher.watchID(threadInfo.id); return () => { threadWatcher.removeID(threadInfo.id); }; }, [threadInfo]); const parentThreadID = threadInfo.parentThreadID; const parentThreadInfo: ?ThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const resolvedParentThreadInfo = useResolvedOptionalThreadInfo(parentThreadInfo); const threadMembers = threadInfo.members; const boundChildThreadInfos = useSelector( state => childThreadInfos(state)[threadID], ); const resolvedChildThreadInfos = useResolvedOptionalThreadInfos( boundChildThreadInfos, ); const somethingIsSaving = useSelector(state => { const editNameLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:name`, )(state); const editColorLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:color`, )(state); const editDescriptionLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:description`, )(state); const leaveThreadLoadingStatus = createLoadingStatusSelector( leaveThreadActionTypes, `${leaveThreadActionTypes.started}:${threadID}`, )(state); const boundThreadMembersChangeIsSaving = threadMembersChangeIsSaving( state, threadMembers, ); return ( boundThreadMembersChangeIsSaving || editNameLoadingStatus === 'loading' || editColorLoadingStatus === 'loading' || editDescriptionLoadingStatus === 'loading' || leaveThreadLoadingStatus === 'loading' ); }); const { navigation } = props; React.useEffect(() => { const tabNavigation: ?TabNavigationProp<'Chat'> = navigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); const onTabPress = () => { if (navigation.isFocused() && !somethingIsSaving) { navigation.popToTop(); } }; tabNavigation.addListener('tabPress', onTabPress); return () => tabNavigation.removeListener('tabPress', onTabPress); }, [navigation, somethingIsSaving]); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); const { canPromoteSidebar } = usePromoteSidebar(threadInfo); return ( ); }); export default ConnectedThreadSettings; diff --git a/native/chat/sidebar-navigation.js b/native/chat/sidebar-navigation.js index dcc3ee13a..a05e40bc9 100644 --- a/native/chat/sidebar-navigation.js +++ b/native/chat/sidebar-navigation.js @@ -1,129 +1,130 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ENSCacheContext } from 'lib/components/ens-cache-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'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo, 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 { 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 { +): ?ThreadInfo | ?MinimallyEncodedThreadInfo { 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, }; async function getSidebarThreadInfo( input: GetSidebarThreadInfoInput, -): Promise { +): Promise { const { sourceMessage, loggedInUserInfo, getENSNames, 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, }); } function useNavigateToSidebar( item: ChatMessageInfoItemWithHeight, ): () => mixed { const loggedInUserInfo = useLoggedInUserInfo(); const navigateToThread = useNavigateToThread(); const cacheContext = React.useContext(ENSCacheContext); const chatMentionCandidates = useThreadChatMentionCandidates(item.threadInfo); const { getENSNames } = cacheContext; return React.useCallback(async () => { const threadInfo = await getSidebarThreadInfo({ sourceMessage: item, loggedInUserInfo, getENSNames, chatMentionCandidates, }); invariant(threadInfo, 'threadInfo should be set'); navigateToThread({ threadInfo }); }, [ item, loggedInUserInfo, getENSNames, 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/thread-settings-button.react.js b/native/chat/thread-settings-button.react.js index c84b68fdd..e6a49e01d 100644 --- a/native/chat/thread-settings-button.react.js +++ b/native/chat/thread-settings-button.react.js @@ -1,61 +1,62 @@ // @flow import * as React from 'react'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import type { ChatNavigationProp } from './chat.react.js'; import Button from '../components/button.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { ThreadSettingsRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; type BaseProps = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +navigate: $PropertyType, 'navigate'>, }; type Props = { ...BaseProps, +styles: typeof unboundStyles, }; class ThreadSettingsButton extends React.PureComponent { render() { return ( ); } onPress = () => { const threadInfo = this.props.threadInfo; this.props.navigate<'ThreadSettings'>({ name: ThreadSettingsRouteName, params: { threadInfo }, key: `${ThreadSettingsRouteName}${threadInfo.id}`, }); }; } const unboundStyles = { button: { color: 'panelForegroundLabel', paddingHorizontal: 10, }, }; const ConnectedThreadSettingsButton: React.ComponentType = React.memo(function ConnectedThreadSettingsButton( props: BaseProps, ) { const styles = useStyles(unboundStyles); return ; }); export default ConnectedThreadSettingsButton; diff --git a/native/chat/thread-settings-header-title.react.js b/native/chat/thread-settings-header-title.react.js index 4363867d7..4c5af2a84 100644 --- a/native/chat/thread-settings-header-title.react.js +++ b/native/chat/thread-settings-header-title.react.js @@ -1,26 +1,27 @@ // @flow import { HeaderTitle, type HeaderTitleInputProps, } from '@react-navigation/elements'; import * as React from 'react'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { firstLine } from 'lib/utils/string-utils.js'; type Props = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, ...HeaderTitleInputProps, }; function ThreadSettingsHeaderTitle(props: Props): React.Node { const { threadInfo, ...rest } = props; const { uiName } = useResolvedThreadInfo(threadInfo); return {firstLine(uiName)}; } const MemoizedThreadSettingsHeaderTitle: React.ComponentType = React.memo(ThreadSettingsHeaderTitle); export default MemoizedThreadSettingsHeaderTitle; diff --git a/native/chat/utils.js b/native/chat/utils.js index b30a74b53..2aafa8f73 100644 --- a/native/chat/utils.js +++ b/native/chat/utils.js @@ -1,445 +1,446 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import Animated from 'react-native-reanimated'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { messageKey } from 'lib/shared/message-utils.js'; import { viewerIsMember } from 'lib/shared/thread-utils.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { clusterEndHeight } from './chat-constants.js'; import { ChatContext, useHeightMeasurer } from './chat-context.js'; import { failedSendHeight } from './failed-send.react.js'; import { useNativeMessageListData, type NativeChatMessageItem, } from './message-data.react.js'; import { authorNameHeight } from './message-header.react.js'; import { multimediaMessageItemHeight } from './multimedia-message-utils.js'; import { getUnresolvedSidebarThreadInfo } from './sidebar-navigation.js'; import textMessageSendFailed from './text-message-send-failed.js'; import { timestampHeight } from './timestamp.react.js'; import { KeyboardContext } from '../keyboard/keyboard-state.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import { MultimediaMessageTooltipModalRouteName, RobotextMessageTooltipModalRouteName, TextMessageTooltipModalRouteName, } from '../navigation/route-names.js'; import type { ChatMessageInfoItemWithHeight, ChatMessageItemWithHeight, ChatTextMessageInfoItemWithHeight, } from '../types/chat-types.js'; import type { LayoutCoordinates, VerticalBounds, } from '../types/layout-types.js'; import type { AnimatedViewStyle } from '../types/styles.js'; /* eslint-disable import/no-named-as-default-member */ const { Node, Extrapolate, interpolateNode, interpolateColors, block, call, eq, cond, sub, } = Animated; /* eslint-enable import/no-named-as-default-member */ function textMessageItemHeight( item: ChatTextMessageInfoItemWithHeight, ): number { const { messageInfo, contentHeight, startsCluster, endsCluster } = item; const { isViewer } = messageInfo.creator; let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage if (!isViewer && startsCluster) { height += authorNameHeight; } if (endsCluster) { height += clusterEndHeight; } if (textMessageSendFailed(item)) { height += failedSendHeight; } return height; } function messageItemHeight(item: ChatMessageInfoItemWithHeight): number { let height = 0; if (item.messageShapeType === 'text') { height += textMessageItemHeight(item); } else if (item.messageShapeType === 'multimedia') { height += multimediaMessageItemHeight(item); } else { height += item.contentHeight; } if (item.startsConversation) { height += timestampHeight; } return height; } function chatMessageItemHeight(item: ChatMessageItemWithHeight): number { if (item.itemType === 'loader') { return 56; } return messageItemHeight(item); } function useMessageTargetParameters( sourceMessage: ChatMessageInfoItemWithHeight, initialCoordinates: LayoutCoordinates, messageListVerticalBounds: VerticalBounds, currentInputBarHeight: number, targetInputBarHeight: number, - sidebarThreadInfo: ?ThreadInfo, + sidebarThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, ): { +position: number, +color: string, } { const messageListData = useNativeMessageListData({ searching: false, userInfoInputArray: [], threadInfo: sidebarThreadInfo, }); const [messagesWithHeight, setMessagesWithHeight] = React.useState>(null); const measureMessages = useHeightMeasurer(); React.useEffect(() => { if (messageListData) { measureMessages( messageListData, sidebarThreadInfo, setMessagesWithHeight, ); } }, [measureMessages, messageListData, sidebarThreadInfo]); const sourceMessageID = sourceMessage.messageInfo?.id; const targetDistanceFromBottom = React.useMemo(() => { if (!messagesWithHeight) { return 0; } let offset = 0; for (const message of messagesWithHeight) { offset += chatMessageItemHeight(message); if (message.messageInfo && message.messageInfo.id === sourceMessageID) { return offset; } } return ( messageListVerticalBounds.height + chatMessageItemHeight(sourceMessage) ); }, [ messageListVerticalBounds.height, messagesWithHeight, sourceMessage, sourceMessageID, ]); if (!sidebarThreadInfo) { return { position: 0, color: sourceMessage.threadInfo.color, }; } const authorNameComponentHeight = sourceMessage.messageInfo.creator.isViewer ? 0 : authorNameHeight; const currentDistanceFromBottom = messageListVerticalBounds.height + messageListVerticalBounds.y - initialCoordinates.y + timestampHeight + authorNameComponentHeight + currentInputBarHeight; return { position: targetDistanceFromBottom + targetInputBarHeight - currentDistanceFromBottom, color: sidebarThreadInfo.color, }; } type AnimatedMessageArgs = { +sourceMessage: ChatMessageInfoItemWithHeight, +initialCoordinates: LayoutCoordinates, +messageListVerticalBounds: VerticalBounds, +progress: Node, +targetInputBarHeight: ?number, }; function useAnimatedMessageTooltipButton({ sourceMessage, initialCoordinates, messageListVerticalBounds, progress, targetInputBarHeight, }: AnimatedMessageArgs): { +style: AnimatedViewStyle, +threadColorOverride: ?Node, +isThreadColorDarkOverride: ?boolean, } { const chatContext = React.useContext(ChatContext); invariant(chatContext, 'chatContext should be set'); const { currentTransitionSidebarSourceID, setCurrentTransitionSidebarSourceID, chatInputBarHeights, sidebarAnimationType, setSidebarAnimationType, } = chatContext; const loggedInUserInfo = useLoggedInUserInfo(); const chatMentionCandidates = useThreadChatMentionCandidates( sourceMessage.threadInfo, ); const sidebarThreadInfo = React.useMemo( () => getUnresolvedSidebarThreadInfo({ sourceMessage, loggedInUserInfo, chatMentionCandidates, }), [sourceMessage, loggedInUserInfo, chatMentionCandidates], ); const currentInputBarHeight = chatInputBarHeights.get(sourceMessage.threadInfo.id) ?? 0; const keyboardState = React.useContext(KeyboardContext); const newSidebarAnimationType = !currentInputBarHeight || !targetInputBarHeight || keyboardState?.keyboardShowing || !viewerIsMember(sidebarThreadInfo) ? 'fade_source_message' : 'move_source_message'; React.useEffect(() => { setSidebarAnimationType(newSidebarAnimationType); }, [setSidebarAnimationType, newSidebarAnimationType]); const { position: targetPosition, color: targetColor } = useMessageTargetParameters( sourceMessage, initialCoordinates, messageListVerticalBounds, currentInputBarHeight, targetInputBarHeight ?? currentInputBarHeight, sidebarThreadInfo, ); React.useEffect(() => { return () => setCurrentTransitionSidebarSourceID(null); }, [setCurrentTransitionSidebarSourceID]); const bottom = React.useMemo( () => interpolateNode(progress, { inputRange: [0.3, 1], outputRange: [targetPosition, 0], extrapolate: Extrapolate.CLAMP, }), [progress, targetPosition], ); const [isThreadColorDarkOverride, setThreadColorDarkOverride] = React.useState(null); const setThreadColorBrightness = React.useCallback(() => { const isSourceThreadDark = colorIsDark(sourceMessage.threadInfo.color); const isTargetThreadDark = colorIsDark(targetColor); if (isSourceThreadDark !== isTargetThreadDark) { setThreadColorDarkOverride(isTargetThreadDark); } }, [sourceMessage.threadInfo.color, targetColor]); const threadColorOverride = React.useMemo(() => { if ( sourceMessage.messageShapeType !== 'text' || !currentTransitionSidebarSourceID ) { return null; } return block([ cond(eq(progress, 1), call([], setThreadColorBrightness)), interpolateColors(progress, { inputRange: [0, 1], outputColorRange: [ `#${targetColor}`, `#${sourceMessage.threadInfo.color}`, ], }), ]); }, [ currentTransitionSidebarSourceID, progress, setThreadColorBrightness, sourceMessage.messageShapeType, sourceMessage.threadInfo.color, targetColor, ]); const messageContainerStyle = React.useMemo(() => { return { bottom: currentTransitionSidebarSourceID ? bottom : 0, opacity: currentTransitionSidebarSourceID && sidebarAnimationType === 'fade_source_message' ? 0 : 1, }; }, [bottom, currentTransitionSidebarSourceID, sidebarAnimationType]); return { style: messageContainerStyle, threadColorOverride, isThreadColorDarkOverride, }; } function getMessageTooltipKey(item: ChatMessageInfoItemWithHeight): string { return `tooltip|${messageKey(item.messageInfo)}`; } function isMessageTooltipKey(key: string): boolean { return key.startsWith('tooltip|'); } function useOverlayPosition(item: ChatMessageInfoItemWithHeight) { const overlayContext = React.useContext(OverlayContext); invariant(overlayContext, 'should be set'); for (const overlay of overlayContext.visibleOverlays) { if ( (overlay.routeName === MultimediaMessageTooltipModalRouteName || overlay.routeName === TextMessageTooltipModalRouteName || overlay.routeName === RobotextMessageTooltipModalRouteName) && overlay.routeKey === getMessageTooltipKey(item) ) { return overlay.position; } } return undefined; } function useContentAndHeaderOpacity( item: ChatMessageInfoItemWithHeight, ): number | Node { const overlayPosition = useOverlayPosition(item); const chatContext = React.useContext(ChatContext); return React.useMemo( () => overlayPosition && chatContext?.sidebarAnimationType === 'move_source_message' ? sub( 1, interpolateNode(overlayPosition, { inputRange: [0.05, 0.06], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }), ) : 1, [chatContext?.sidebarAnimationType, overlayPosition], ); } function useDeliveryIconOpacity( item: ChatMessageInfoItemWithHeight, ): number | Node { const overlayPosition = useOverlayPosition(item); const chatContext = React.useContext(ChatContext); return React.useMemo(() => { if ( !overlayPosition || !chatContext?.currentTransitionSidebarSourceID || chatContext?.sidebarAnimationType === 'fade_source_message' ) { return 1; } return interpolateNode(overlayPosition, { inputRange: [0.05, 0.06, 1], outputRange: [1, 0, 0], extrapolate: Extrapolate.CLAMP, }); }, [ chatContext?.currentTransitionSidebarSourceID, chatContext?.sidebarAnimationType, overlayPosition, ]); } function chatMessageItemKey( item: ChatMessageItemWithHeight | NativeChatMessageItem, ): string { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } function modifyItemForResultScreen( item: ChatMessageInfoItemWithHeight, ): ChatMessageInfoItemWithHeight { if (item.messageShapeType === 'robotext') { return item; } if (item.messageShapeType === 'multimedia') { return { ...item, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; } return { ...item, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; } export { chatMessageItemKey, chatMessageItemHeight, useAnimatedMessageTooltipButton, messageItemHeight, getMessageTooltipKey, isMessageTooltipKey, useContentAndHeaderOpacity, useDeliveryIconOpacity, modifyItemForResultScreen, }; diff --git a/native/components/community-pill.react.js b/native/components/community-pill.react.js index 19db27029..5d02019bf 100644 --- a/native/components/community-pill.react.js +++ b/native/components/community-pill.react.js @@ -1,68 +1,69 @@ // @flow import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import { useKeyserverAdmin } from 'lib/shared/user-utils.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import CommIcon from './comm-icon.react.js'; import Pill from './pill.react.js'; import ThreadPill from './thread-pill.react.js'; import { useColors } from '../themes/colors.js'; const threadPillRoundCorners = { left: false, right: true }; type Props = { - +community: ThreadInfo, + +community: ThreadInfo | MinimallyEncodedThreadInfo, }; function CommunityPill(props: Props): React.Node { const { community } = props; const keyserverAdmin = useKeyserverAdmin(community); const keyserverOperatorUsername = keyserverAdmin?.username; const colors = useColors(); const keyserverOperatorLabel: ?React.Node = React.useMemo(() => { if (!keyserverOperatorUsername) { return undefined; } const icon = ( ); return ( ); }, [ colors.codeBackground, colors.panelForegroundLabel, keyserverOperatorUsername, ]); return ( {keyserverOperatorLabel} ); } const styles = StyleSheet.create({ container: { flexDirection: 'row', }, }); export default CommunityPill; diff --git a/native/components/thread-pill.react.js b/native/components/thread-pill.react.js index a5a70cfe5..20041a24a 100644 --- a/native/components/thread-pill.react.js +++ b/native/components/thread-pill.react.js @@ -1,28 +1,29 @@ // @flow import * as React from 'react'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import Pill from './pill.react.js'; type Props = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +roundCorners?: { +left: boolean, +right: boolean }, +fontSize?: number, }; function ThreadPill(props: Props): React.Node { const { threadInfo, roundCorners, fontSize } = props; const { uiName } = useResolvedThreadInfo(threadInfo); return ( ); } export default ThreadPill; diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index 98e94e461..7dc35e6a5 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1727 +1,1745 @@ // @flow import * as FileSystem from 'expo-file-system'; import invariant from 'invariant'; import * as React from 'react'; import { Platform } from 'react-native'; import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, useSendMultimediaMessage, sendTextMessageActionTypes, useSendTextMessage, } from 'lib/actions/message-actions.js'; import type { SendMultimediaMessageInput, SendTextMessageInput, } from 'lib/actions/message-actions.js'; import { queueReportsActionType } from 'lib/actions/report-actions.js'; import { useNewThread } from 'lib/actions/thread-actions.js'; import { uploadMultimedia, updateMultimediaMessageMediaActionType, useBlobServiceUpload, type MultimediaUploadCallbacks, type MultimediaUploadExtras, type BlobServiceUploadAction, } from 'lib/actions/upload-actions.js'; import commStaffCommunity from 'lib/facts/comm-staff-community.js'; import { pathFromURI, replaceExtension } from 'lib/media/file-utils.js'; import { isLocalUploadID, getNextLocalUploadID, } from 'lib/media/media-utils.js'; import { videoDurationLimit } from 'lib/media/video-utils.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { createMediaMessageInfo, useNextLocalID, useMessageCreationSideEffectsFunc, } from 'lib/shared/message-utils.js'; import type { CreationSideEffectsFunc } from 'lib/shared/messages/message-spec.js'; import { createRealThreadFromPendingThread, threadIsPending, threadIsPendingSidebar, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { UploadMultimediaResult, Media, NativeMediaSelection, MediaMissionResult, MediaMission, } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessageResult, type SendMessagePayload, } from 'lib/types/message-types.js'; import type { RawImagesMessageInfo } from 'lib/types/messages/images.js'; import type { RawMediaMessageInfo } from 'lib/types/messages/media.js'; import { getMediaMessageServerDBContentsFromMedia } from 'lib/types/messages/media.js'; import type { RawTextMessageInfo } from 'lib/types/messages/text.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ClientMediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type ClientNewThreadRequest, type NewThreadResult, type ThreadInfo, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import type { CallServerEndpointOptions, CallServerEndpointResponse, } from 'lib/utils/call-server-endpoint.js'; import { getConfig } from 'lib/utils/config.js'; import { getMessageForException, cloneError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { generateReportID, useIsReportEnabled, } from 'lib/utils/report-utils.js'; import { type EditInputBarMessageParameters, InputStateContext, type PendingMultimediaUploads, type MultimediaProcessingStep, } from './input-state.js'; import { encryptMedia } from '../media/encryption-utils.js'; import { disposeTempFile } from '../media/file-utils.js'; import { processMedia } from '../media/media-utils.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; import { useCalendarQuery } from '../navigation/nav-selectors.js'; import { useSelector } from '../redux/redux-utils.js'; import blobServiceUploadHandler from '../utils/blob-service-upload.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; type MediaIDs = | { +type: 'photo', +localMediaID: string } | { +type: 'video', +localMediaID: string, +localThumbnailID: string }; type UploadFileInput = { +selection: NativeMediaSelection, +ids: MediaIDs, }; type CompletedUploads = { +[localMessageID: string]: ?Set }; type BaseProps = { +children: React.Node, }; type Props = { ...BaseProps, +viewerID: ?string, +nextLocalID: string, +messageStoreMessages: { +[id: string]: RawMessageInfo }, +ongoingMessageCreation: boolean, +hasWiFi: boolean, +mediaReportsEnabled: boolean, +calendarQuery: () => CalendarQuery, +dispatch: Dispatch, +staffCanSee: boolean, +dispatchActionPromise: DispatchActionPromise, +uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, +blobServiceUpload: BlobServiceUploadAction, +sendMultimediaMessage: ( input: SendMultimediaMessageInput, ) => Promise, +sendTextMessage: (input: SendTextMessageInput) => Promise, +newThread: (request: ClientNewThreadRequest) => Promise, +textMessageCreationSideEffectsFunc: CreationSideEffectsFunc, }; type State = { +pendingUploads: PendingMultimediaUploads, }; class InputStateContainer extends React.PureComponent { state: State = { pendingUploads: {}, }; sendCallbacks: Array<() => void> = []; activeURIs = new Map(); editInputBarCallbacks: Array< (params: EditInputBarMessageParameters) => void, > = []; scrollToMessageCallbacks: Array<(messageID: string) => void> = []; pendingThreadCreations = new Map>(); - pendingThreadUpdateHandlers = new Map mixed>(); + pendingThreadUpdateHandlers = new Map< + string, + (ThreadInfo | MinimallyEncodedThreadInfo) => mixed, + >(); // TODO: flip the switch // Note that this enables Blob service for encrypted media only useBlobServiceUploads = false; // When the user sends a multimedia message that triggers the creation of a // sidebar, the sidebar gets created right away, but the message needs to wait // for the uploads to complete before sending. We use this Set to track the // message localIDs that need sidebarCreation: true. pendingSidebarCreationMessageLocalIDs = new Set(); static getCompletedUploads(props: Props, state: State): CompletedUploads { const completedUploads = {}; for (const localMessageID in state.pendingUploads) { const messagePendingUploads = state.pendingUploads[localMessageID]; const rawMessageInfo = props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); let allUploadsComplete = true; const completedUploadIDs = new Set(Object.keys(messagePendingUploads)); for (const singleMedia of rawMessageInfo.media) { if (isLocalUploadID(singleMedia.id)) { allUploadsComplete = false; completedUploadIDs.delete(singleMedia.id); } } if (allUploadsComplete) { completedUploads[localMessageID] = null; } else if (completedUploadIDs.size > 0) { completedUploads[localMessageID] = completedUploadIDs; } } return completedUploads; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const currentlyComplete = InputStateContainer.getCompletedUploads( this.props, this.state, ); const previouslyComplete = InputStateContainer.getCompletedUploads( prevProps, prevState, ); const newPendingUploads = {}; let pendingUploadsChanged = false; const readyMessageIDs = []; for (const localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; const prevRawMessageInfo = prevProps.messageStoreMessages[localMessageID]; const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; const completedUploadIDs = currentlyComplete[localMessageID]; const previouslyCompletedUploadIDs = previouslyComplete[localMessageID]; if (!rawMessageInfo && prevRawMessageInfo) { pendingUploadsChanged = true; continue; } else if (completedUploadIDs === null) { // All of this message's uploads have been completed newPendingUploads[localMessageID] = {}; if (previouslyCompletedUploadIDs !== null) { readyMessageIDs.push(localMessageID); pendingUploadsChanged = true; } continue; } else if (!completedUploadIDs) { // Nothing has been completed newPendingUploads[localMessageID] = messagePendingUploads; continue; } const newUploads = {}; let uploadsChanged = false; for (const localUploadID in messagePendingUploads) { if (!completedUploadIDs.has(localUploadID)) { newUploads[localUploadID] = messagePendingUploads[localUploadID]; } else if ( !previouslyCompletedUploadIDs || !previouslyCompletedUploadIDs.has(localUploadID) ) { uploadsChanged = true; } } if (uploadsChanged) { pendingUploadsChanged = true; newPendingUploads[localMessageID] = newUploads; } else { newPendingUploads[localMessageID] = messagePendingUploads; } } if (pendingUploadsChanged) { this.setState({ pendingUploads: newPendingUploads }); } for (const localMessageID of readyMessageIDs) { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); this.dispatchMultimediaMessageAction(rawMessageInfo); } } async dispatchMultimediaMessageAction(messageInfo: RawMultimediaMessageInfo) { if (!threadIsPending(messageInfo.threadID)) { this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: messageInfo, }); let newThreadID = null; try { const threadCreationPromise = this.pendingThreadCreations.get( messageInfo.threadID, ); if (!threadCreationPromise) { // When we create or retry multimedia message, we add a promise to // pendingThreadCreations map. This promise can be removed in // sendMultimediaMessage and sendTextMessage methods. When any of these // method remove the promise, it has to be settled. If the promise was // fulfilled, this method would be called with realized thread, so we // can conclude that the promise was rejected. We don't have enough info // here to retry the thread creation, but we can mark the message as // failed. Then the retry will be possible and promise will be created // again. throw new Error('Thread creation failed'); } newThreadID = await threadCreationPromise; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendMultimediaMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(messageInfo.threadID); } const newMessageInfo = { ...messageInfo, threadID: newThreadID, time: Date.now(), }; this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(newMessageInfo), undefined, newMessageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); const mediaMessageContents = getMediaMessageServerDBContentsFromMedia( messageInfo.media, ); try { const result = await this.props.sendMultimediaMessage({ threadID, localID, mediaMessageContents, sidebarCreation, }); this.pendingSidebarCreationMessageLocalIDs.delete(localID); return { localID, serverID: result.id, threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } } inputStateSelector = createSelector( (state: State) => state.pendingUploads, (pendingUploads: PendingMultimediaUploads) => ({ pendingUploads, sendTextMessage: this.sendTextMessage, sendMultimediaMessage: this.sendMultimediaMessage, editInputMessage: this.editInputMessage, addEditInputMessageListener: this.addEditInputMessageListener, removeEditInputMessageListener: this.removeEditInputMessageListener, messageHasUploadFailure: this.messageHasUploadFailure, retryMessage: this.retryMessage, registerSendCallback: this.registerSendCallback, unregisterSendCallback: this.unregisterSendCallback, uploadInProgress: this.uploadInProgress, reportURIDisplayed: this.reportURIDisplayed, setPendingThreadUpdateHandler: this.setPendingThreadUpdateHandler, scrollToMessage: this.scrollToMessage, addScrollToMessageListener: this.addScrollToMessageListener, removeScrollToMessageListener: this.removeScrollToMessageListener, }), ); scrollToMessage = (messageID: string) => { this.scrollToMessageCallbacks.forEach(callback => callback(messageID)); }; addScrollToMessageListener = (callback: (messageID: string) => void) => { this.scrollToMessageCallbacks.push(callback); }; removeScrollToMessageListener = ( callbackScrollToMessage: (messageID: string) => void, ) => { this.scrollToMessageCallbacks = this.scrollToMessageCallbacks.filter( candidate => candidate !== callbackScrollToMessage, ); }; uploadInProgress = () => { if (this.props.ongoingMessageCreation) { return true; } const { pendingUploads } = this.state; return values(pendingUploads).some(messagePendingUploads => values(messagePendingUploads).some(upload => !upload.failed), ); }; sendTextMessage = async ( messageInfo: RawTextMessageInfo, - inputThreadInfo: ThreadInfo, - parentThreadInfo: ?ThreadInfo, + inputThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); if (threadIsPendingSidebar(inputThreadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localID); } if (!threadIsPending(inputThreadInfo.id)) { this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction( messageInfo, inputThreadInfo, parentThreadInfo, ), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendTextMessageActionTypes.started, payload: messageInfo, }); let threadInfo = inputThreadInfo; const { viewerID } = this.props; if (viewerID && inputThreadInfo.type === threadTypes.SIDEBAR) { invariant(parentThreadInfo, 'sidebar should have parent'); threadInfo = patchThreadInfoToIncludeMentionedMembersOfParent( inputThreadInfo, parentThreadInfo, messageInfo.text, viewerID, ); if (threadInfo !== inputThreadInfo) { const pendingThreadUpdateHandler = this.pendingThreadUpdateHandlers.get( threadInfo.id, ); pendingThreadUpdateHandler?.(threadInfo); } } let newThreadID = null; try { newThreadID = await this.startThreadCreation(threadInfo); } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendTextMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(threadInfo.id); } const newMessageInfo = { ...messageInfo, threadID: newThreadID, time: Date.now(), }; - const newThreadInfo = { - ...threadInfo, - id: newThreadID, - }; + + // Branching to appease `flow`. + const newThreadInfo = threadInfo.minimallyEncoded + ? { + ...threadInfo, + id: newThreadID, + } + : { + ...threadInfo, + id: newThreadID, + }; + this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction( newMessageInfo, newThreadInfo, parentThreadInfo, ), undefined, newMessageInfo, ); }; - startThreadCreation(threadInfo: ThreadInfo): Promise { + startThreadCreation( + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + ): Promise { if (!threadIsPending(threadInfo.id)) { return Promise.resolve(threadInfo.id); } let threadCreationPromise = this.pendingThreadCreations.get(threadInfo.id); if (!threadCreationPromise) { const calendarQuery = this.props.calendarQuery(); threadCreationPromise = createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise: this.props.dispatchActionPromise, createNewThread: this.props.newThread, sourceMessageID: threadInfo.sourceMessageID, viewerID: this.props.viewerID, calendarQuery, }); this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise); } return threadCreationPromise; } async sendTextMessageAction( messageInfo: RawTextMessageInfo, - threadInfo: ThreadInfo, - parentThreadInfo: ?ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, ): Promise { try { await this.props.textMessageCreationSideEffectsFunc( messageInfo, threadInfo, parentThreadInfo, ); const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); const result = await this.props.sendTextMessage({ threadID: messageInfo.threadID, localID, text: messageInfo.text, sidebarCreation, }); this.pendingSidebarCreationMessageLocalIDs.delete(localID); return { localID, serverID: result.id, threadID: messageInfo.threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; throw copy; } } - shouldEncryptMedia(threadInfo: ThreadInfo): boolean { + shouldEncryptMedia( + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + ): boolean { return threadInfoInsideCommunity(threadInfo, commStaffCommunity.id); } sendMultimediaMessage = async ( selections: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const localMessageID = this.props.nextLocalID; this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } const uploadFileInputs = [], media = []; for (const selection of selections) { const localMediaID = getNextLocalUploadID(); let ids; if ( selection.step === 'photo_library' || selection.step === 'photo_capture' || selection.step === 'photo_paste' ) { media.push({ id: localMediaID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, thumbHash: null, }); ids = { type: 'photo', localMediaID }; } const localThumbnailID = getNextLocalUploadID(); if (selection.step === 'video_library') { media.push({ id: localMediaID, uri: selection.uri, type: 'video', dimensions: selection.dimensions, localMediaSelection: selection, loop: false, thumbnailID: localThumbnailID, thumbnailURI: selection.uri, thumbnailThumbHash: null, }); ids = { type: 'video', localMediaID, localThumbnailID }; } invariant(ids, `unexpected MediaSelection ${selection.step}`); uploadFileInputs.push({ selection, ids }); } const pendingUploads = {}; for (const uploadFileInput of uploadFileInputs) { const { localMediaID } = uploadFileInput.ids; pendingUploads[localMediaID] = { failed: false, progressPercent: 0, processingStep: null, }; if (uploadFileInput.ids.type === 'video') { const { localThumbnailID } = uploadFileInput.ids; pendingUploads[localThumbnailID] = { failed: false, progressPercent: 0, processingStep: null, }; } } this.setState( prevState => { return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, }; }, () => { const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const messageInfo = createMediaMessageInfo( { localID: localMessageID, threadID: threadInfo.id, creatorID, media, }, { forceMultimediaMessageType: this.shouldEncryptMedia(threadInfo) }, ); this.props.dispatch({ type: createLocalMessageActionType, payload: messageInfo, }); }, ); await this.uploadFiles(localMessageID, uploadFileInputs, threadInfo); }; async uploadFiles( localMessageID: string, uploadFileInputs: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, ) { const results = await Promise.all( uploadFileInputs.map(uploadFileInput => this.uploadFile(localMessageID, uploadFileInput, threadInfo), ), ); const errors = [...new Set(results.filter(Boolean))]; if (errors.length > 0) { displayActionResultModal(errors.join(', ') + ' :('); } } async uploadFile( localMessageID: string, uploadFileInput: UploadFileInput, - threadInfo: ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, ): Promise { const { ids, selection } = uploadFileInput; const { localMediaID } = ids; const start = selection.sendTime; const steps = [selection]; let encryptionSteps = []; let serverID; let userTime; let errorMessage; let reportPromise; const filesToDispose = []; const onUploadFinished = async (result: MediaMissionResult) => { if (!this.props.mediaReportsEnabled) { return errorMessage; } if (reportPromise) { const finalSteps = await reportPromise; steps.push(...finalSteps); steps.push(...encryptionSteps); } const totalTime = Date.now() - start; userTime = userTime ? userTime : totalTime; this.queueMediaMissionReport( { localID: localMediaID, localMessageID, serverID }, { steps, result, totalTime, userTime }, ); return errorMessage; }; const onUploadFailed = (mediaID: string, message: string) => { errorMessage = message; this.handleUploadFailure(localMessageID, mediaID); userTime = Date.now() - start; }; const onTranscodingProgress = (percent: number) => { this.setProgress(localMessageID, localMediaID, 'transcoding', percent); }; let processedMedia; const processingStart = Date.now(); try { const processMediaReturn = processMedia(selection, { hasWiFi: this.props.hasWiFi, finalFileHeaderCheck: this.props.staffCanSee, onTranscodingProgress, }); reportPromise = processMediaReturn.reportPromise; const processResult = await processMediaReturn.resultPromise; if (!processResult.success) { const message = processResult.reason === 'video_too_long' ? `can't do vids longer than ${videoDurationLimit}min` : 'processing failed'; onUploadFailed(localMediaID, message); return await onUploadFinished(processResult); } if (processResult.shouldDisposePath) { filesToDispose.push(processResult.shouldDisposePath); } processedMedia = processResult; } catch (e) { onUploadFailed(localMediaID, 'processing failed'); return await onUploadFinished({ success: false, reason: 'processing_exception', time: Date.now() - processingStart, exceptionMessage: getMessageForException(e), }); } if (this.shouldEncryptMedia(threadInfo)) { const encryptionStart = Date.now(); try { const { result: encryptionResult, ...encryptionReturn } = await encryptMedia(processedMedia); encryptionSteps = encryptionReturn.steps; if (!encryptionResult.success) { onUploadFailed(localMediaID, encryptionResult.reason); return await onUploadFinished(encryptionResult); } if (encryptionResult.shouldDisposePath) { filesToDispose.push(encryptionResult.shouldDisposePath); } processedMedia = encryptionResult; } catch (e) { onUploadFailed(localMediaID, 'encryption failed'); return await onUploadFinished({ success: false, reason: 'encryption_exception', time: Date.now() - encryptionStart, exceptionMessage: getMessageForException(e), }); } } const { uploadURI, filename, mime } = processedMedia; const { hasWiFi } = this.props; const uploadStart = Date.now(); let uploadExceptionMessage, uploadResult, uploadThumbnailResult, mediaMissionResult; try { const uploadPromises = []; if ( this.useBlobServiceUploads && (processedMedia.mediaType === 'encrypted_photo' || processedMedia.mediaType === 'encrypted_video') ) { uploadPromises.push( this.props.blobServiceUpload({ uploadInput: { blobInput: { type: 'uri', uri: uploadURI, filename: filename, mimeType: mime, }, blobHash: processedMedia.blobHash, encryptionKey: processedMedia.encryptionKey, dimensions: processedMedia.dimensions, thumbHash: processedMedia.mediaType === 'encrypted_photo' ? processedMedia.thumbHash : null, }, keyserverOrThreadID: threadInfo.id, callbacks: { blobServiceUploadHandler, onProgress: (percent: number) => { this.setProgress( localMessageID, localMediaID, 'uploading', percent, ); }, }, }), ); if (processedMedia.mediaType === 'encrypted_video') { uploadPromises.push( this.props.blobServiceUpload({ uploadInput: { blobInput: { type: 'uri', uri: processedMedia.uploadThumbnailURI, filename: replaceExtension(`thumb${filename}`, 'jpg'), mimeType: 'image/jpeg', }, blobHash: processedMedia.thumbnailBlobHash, encryptionKey: processedMedia.thumbnailEncryptionKey, loop: false, dimensions: processedMedia.dimensions, thumbHash: processedMedia.thumbHash, }, keyserverOrThreadID: threadInfo.id, callbacks: { blobServiceUploadHandler, }, }), ); } [uploadResult, uploadThumbnailResult] = await Promise.all( uploadPromises, ); } else { uploadPromises.push( this.props.uploadMultimedia( { uri: uploadURI, name: filename, type: mime }, { ...processedMedia.dimensions, loop: processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video' ? processedMedia.loop : undefined, encryptionKey: processedMedia.encryptionKey, thumbHash: processedMedia.mediaType === 'photo' || processedMedia.mediaType === 'encrypted_photo' ? processedMedia.thumbHash : null, }, { onProgress: (percent: number) => this.setProgress( localMessageID, localMediaID, 'uploading', percent, ), uploadBlob: this.uploadBlob, }, ), ); if ( processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video' ) { uploadPromises.push( this.props.uploadMultimedia( { uri: processedMedia.uploadThumbnailURI, name: replaceExtension(`thumb${filename}`, 'jpg'), type: 'image/jpeg', }, { ...processedMedia.dimensions, loop: false, encryptionKey: processedMedia.thumbnailEncryptionKey, thumbHash: processedMedia.thumbHash, }, { uploadBlob: this.uploadBlob, }, ), ); } [uploadResult, uploadThumbnailResult] = await Promise.all( uploadPromises, ); } mediaMissionResult = { success: true }; } catch (e) { uploadExceptionMessage = getMessageForException(e); onUploadFailed(localMediaID, 'upload failed'); mediaMissionResult = { success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }; } if ( ((processedMedia.mediaType === 'photo' || processedMedia.mediaType === 'encrypted_photo') && uploadResult) || ((processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video') && uploadResult && uploadThumbnailResult) ) { const { encryptionKey } = processedMedia; const { id, uri, dimensions, loop } = uploadResult; serverID = id; const mediaSourcePayload = processedMedia.mediaType === 'encrypted_photo' || processedMedia.mediaType === 'encrypted_video' ? { type: processedMedia.mediaType, blobURI: uri, encryptionKey, } : { type: uploadResult.mediaType, uri, }; let updateMediaPayload = { messageID: localMessageID, currentMediaID: localMediaID, mediaUpdate: { id, ...mediaSourcePayload, dimensions, localMediaSelection: undefined, loop: uploadResult.mediaType === 'video' ? loop : undefined, }, }; if ( processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video' ) { invariant(uploadThumbnailResult, 'uploadThumbnailResult exists'); const { uri: thumbnailURI, id: thumbnailID } = uploadThumbnailResult; const { thumbnailEncryptionKey, thumbHash: thumbnailThumbHash } = processedMedia; if (processedMedia.mediaType === 'encrypted_video') { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbnailID, thumbnailBlobURI: thumbnailURI, thumbnailEncryptionKey, thumbnailThumbHash, }, }; } else { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbnailID, thumbnailURI, thumbnailThumbHash, }, }; } } else { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbHash: processedMedia.thumbHash, }, }; } // When we dispatch this action, it updates Redux and triggers the // componentDidUpdate in this class. componentDidUpdate will handle // calling dispatchMultimediaMessageAction once all the uploads are // complete, and does not wait until this function concludes. this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: updateMediaPayload, }); userTime = Date.now() - start; } const processSteps = await reportPromise; reportPromise = null; steps.push(...processSteps); steps.push(...encryptionSteps); steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: filename, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, hasWiFi, }); const cleanupPromises = []; if (filesToDispose.length > 0) { // If processMedia needed to do any transcoding before upload, we dispose // of the resultant temporary file here. Since the transcoded temporary // file is only used for upload, we can dispose of it after processMedia // (reportPromise) and the upload are complete filesToDispose.forEach(shouldDisposePath => { cleanupPromises.push( (async () => { const disposeStep = await disposeTempFile(shouldDisposePath); steps.push(disposeStep); })(), ); }); } // if there's a thumbnail we'll temporarily unlink it here // instead of in media-utils, will be changed in later diffs if (processedMedia.mediaType === 'video') { const { uploadThumbnailURI } = processedMedia; cleanupPromises.push( (async () => { const { steps: clearSteps, result: thumbnailPath } = await this.waitForCaptureURIUnload(uploadThumbnailURI); steps.push(...clearSteps); if (!thumbnailPath) { return; } const disposeStep = await disposeTempFile(thumbnailPath); steps.push(disposeStep); })(), ); } if (selection.captureTime || selection.step === 'photo_paste') { // If we are uploading a newly captured photo, we dispose of the original // file here. Note that we try to save photo captures to the camera roll // if we have permission. Even if we fail, this temporary file isn't // visible to the user, so there's no point in keeping it around. Since // the initial URI is used in rendering paths, we have to wait for it to // be replaced with the remote URI before we can dispose. Check out the // Multimedia component to see how the URIs get switched out. const captureURI = selection.uri; cleanupPromises.push( (async () => { const { steps: clearSteps, result: capturePath } = await this.waitForCaptureURIUnload(captureURI); steps.push(...clearSteps); if (!capturePath) { return; } const disposeStep = await disposeTempFile(capturePath); steps.push(disposeStep); })(), ); } await Promise.all(cleanupPromises); return await onUploadFinished(mediaMissionResult); } setProgress( localMessageID: string, localUploadID: string, processingStep: MultimediaProcessingStep, progressPercent: number, ) { this.setState(prevState => { const pendingUploads = prevState.pendingUploads[localMessageID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newOutOfHundred = Math.floor(progressPercent * 100); const oldOutOfHundred = Math.floor(pendingUpload.progressPercent * 100); if (newOutOfHundred === oldOutOfHundred) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, processingStep, }, }; return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: newPendingUploads, }, }; }); } uploadBlob = async ( url: string, cookie: ?string, sessionID: ?string, input: { +[key: string]: mixed }, options?: ?CallServerEndpointOptions, ): Promise => { invariant( cookie && input.multimedia && Array.isArray(input.multimedia) && input.multimedia.length === 1 && input.multimedia[0] && typeof input.multimedia[0] === 'object', 'InputStateContainer.uploadBlob sent incorrect input', ); const { uri, name, type } = input.multimedia[0]; invariant( typeof uri === 'string' && typeof name === 'string' && typeof type === 'string', 'InputStateContainer.uploadBlob sent incorrect input', ); const parameters = {}; parameters.cookie = cookie; parameters.filename = name; for (const key in input) { if ( key === 'multimedia' || key === 'cookie' || key === 'sessionID' || key === 'filename' ) { continue; } const value = input[key]; invariant( typeof value === 'string', 'blobUpload calls can only handle string values for non-multimedia keys', ); parameters[key] = value; } let path = uri; if (Platform.OS === 'android') { const resolvedPath = pathFromURI(uri); if (resolvedPath) { path = resolvedPath; } } const uploadTask = FileSystem.createUploadTask( url, path, { uploadType: FileSystem.FileSystemUploadType.MULTIPART, fieldName: 'multimedia', headers: { Accept: 'application/json', }, parameters, }, uploadProgress => { if (options && options.onProgress) { const { totalByteSent, totalBytesExpectedToSend } = uploadProgress; options.onProgress(totalByteSent / totalBytesExpectedToSend); } }, ); if (options && options.abortHandler) { options.abortHandler(() => uploadTask.cancelAsync()); } try { const response = await uploadTask.uploadAsync(); return JSON.parse(response.body); } catch (e) { throw new Error( `Failed to upload blob: ${ getMessageForException(e) ?? 'unknown error' }`, ); } }; handleUploadFailure(localMessageID: string, localUploadID: string) { this.setState(prevState => { const uploads = prevState.pendingUploads[localMessageID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been completed before it failed return {}; } return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: { ...uploads, [localUploadID]: { ...upload, failed: true, progressPercent: 0, }, }, }, }; }); } queueMediaMissionReport( ids: { localID: string, localMessageID: string, serverID: ?string }, mediaMission: MediaMission, ) { const report: ClientMediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID: ids.serverID, uploadLocalID: ids.localID, messageLocalID: ids.localMessageID, id: generateReportID(), }; this.props.dispatch({ type: queueReportsActionType, payload: { reports: [report], }, }); } messageHasUploadFailure = (localMessageID: string) => { const pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { return false; } return values(pendingUploads).some(upload => upload.failed); }; editInputMessage = (params: EditInputBarMessageParameters) => { this.editInputBarCallbacks.forEach(addEditInputBarCallback => addEditInputBarCallback(params), ); }; addEditInputMessageListener = ( callbackEditInputBar: (params: EditInputBarMessageParameters) => void, ) => { this.editInputBarCallbacks.push(callbackEditInputBar); }; removeEditInputMessageListener = ( callbackEditInputBar: (params: EditInputBarMessageParameters) => void, ) => { this.editInputBarCallbacks = this.editInputBarCallbacks.filter( candidate => candidate !== callbackEditInputBar, ); }; retryTextMessage = async ( rawMessageInfo: RawTextMessageInfo, - threadInfo: ThreadInfo, - parentThreadInfo: ?ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, ) => { await this.sendTextMessage( { ...rawMessageInfo, time: Date.now(), }, threadInfo, parentThreadInfo, ); }; retryMultimediaMessage = async ( rawMessageInfo: RawMultimediaMessageInfo, localMessageID: string, - threadInfo: ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, ) => { const pendingUploads = this.state.pendingUploads[localMessageID] ?? {}; const now = Date.now(); this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } const updateMedia = (media: $ReadOnlyArray): T[] => media.map(singleMedia => { invariant( singleMedia.type === 'photo' || singleMedia.type === 'video', 'Retry selection must be unencrypted', ); let updatedMedia = singleMedia; const oldMediaID = updatedMedia.id; if ( // not complete isLocalUploadID(oldMediaID) && // not still ongoing (!pendingUploads[oldMediaID] || pendingUploads[oldMediaID].failed) ) { // If we have an incomplete upload that isn't in pendingUploads, that // indicates the app has restarted. We'll reassign a new localID to // avoid collisions. Note that this isn't necessary for the message ID // since the localID reducer prevents collisions there const mediaID = pendingUploads[oldMediaID] ? oldMediaID : getNextLocalUploadID(); if (updatedMedia.type === 'photo') { updatedMedia = { type: 'photo', ...updatedMedia, id: mediaID, }; } else { updatedMedia = { type: 'video', ...updatedMedia, id: mediaID, }; } } if (updatedMedia.type === 'video') { const oldThumbnailID = updatedMedia.thumbnailID; invariant(oldThumbnailID, 'oldThumbnailID not null or undefined'); if ( // not complete isLocalUploadID(oldThumbnailID) && // not still ongoing (!pendingUploads[oldThumbnailID] || pendingUploads[oldThumbnailID].failed) ) { const thumbnailID = pendingUploads[oldThumbnailID] ? oldThumbnailID : getNextLocalUploadID(); updatedMedia = { ...updatedMedia, thumbnailID, }; } } if (updatedMedia === singleMedia) { return singleMedia; } const oldSelection = updatedMedia.localMediaSelection; invariant( oldSelection, 'localMediaSelection should be set on locally created Media', ); const retries = oldSelection.retries ? oldSelection.retries + 1 : 1; // We switch for Flow let selection; if (oldSelection.step === 'photo_capture') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_library') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_paste') { selection = { ...oldSelection, sendTime: now, retries }; } else { selection = { ...oldSelection, sendTime: now, retries }; } if (updatedMedia.type === 'photo') { return { type: 'photo', ...updatedMedia, localMediaSelection: selection, }; } return { type: 'video', ...updatedMedia, localMediaSelection: selection, }; }); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawMediaMessageInfo); } else if (rawMessageInfo.type === messageTypes.IMAGES) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawImagesMessageInfo); } else { invariant(false, `rawMessageInfo ${localMessageID} should be multimedia`); } const incompleteMedia: Media[] = []; for (const singleMedia of newRawMessageInfo.media) { if (isLocalUploadID(singleMedia.id)) { incompleteMedia.push(singleMedia); } } if (incompleteMedia.length === 0) { this.dispatchMultimediaMessageAction(newRawMessageInfo); this.setState(prevState => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: {}, }, })); return; } const retryMedia = incompleteMedia.filter( ({ id }) => !pendingUploads[id] || pendingUploads[id].failed, ); if (retryMedia.length === 0) { // All media are already in the process of being uploaded return; } // We're not actually starting the send here, // we just use this action to update the message in Redux this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: newRawMessageInfo, }); // We clear out the failed status on individual media here, // which makes the UI show pending status instead of error messages for (const singleMedia of retryMedia) { pendingUploads[singleMedia.id] = { failed: false, progressPercent: 0, processingStep: null, }; if (singleMedia.type === 'video') { const { thumbnailID } = singleMedia; invariant(thumbnailID, 'thumbnailID not null or undefined'); pendingUploads[thumbnailID] = { failed: false, progressPercent: 0, processingStep: null, }; } } this.setState(prevState => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, })); const uploadFileInputs = retryMedia.map(singleMedia => { invariant( singleMedia.localMediaSelection, 'localMediaSelection should be set on locally created Media', ); let ids; if (singleMedia.type === 'photo') { ids = { type: 'photo', localMediaID: singleMedia.id }; } else { invariant( singleMedia.thumbnailID, 'singleMedia.thumbnailID should be set for videos', ); ids = { type: 'video', localMediaID: singleMedia.id, localThumbnailID: singleMedia.thumbnailID, }; } return { selection: singleMedia.localMediaSelection, ids, }; }); await this.uploadFiles(localMessageID, uploadFileInputs, threadInfo); }; retryMessage = async ( localMessageID: string, - threadInfo: ThreadInfo, - parentThreadInfo: ?ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); if (rawMessageInfo.type === messageTypes.TEXT) { await this.retryTextMessage(rawMessageInfo, threadInfo, parentThreadInfo); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { await this.retryMultimediaMessage( rawMessageInfo, localMessageID, threadInfo, ); } }; registerSendCallback = (callback: () => void) => { this.sendCallbacks.push(callback); }; unregisterSendCallback = (callback: () => void) => { this.sendCallbacks = this.sendCallbacks.filter( candidate => candidate !== callback, ); }; reportURIDisplayed = (uri: string, loaded: boolean) => { const prevActiveURI = this.activeURIs.get(uri); const curCount = prevActiveURI && prevActiveURI.count; const prevCount = curCount ? curCount : 0; const count = loaded ? prevCount + 1 : prevCount - 1; const prevOnClear = prevActiveURI && prevActiveURI.onClear; const onClear = prevOnClear ? prevOnClear : []; const activeURI = { count, onClear }; if (count) { this.activeURIs.set(uri, activeURI); return; } this.activeURIs.delete(uri); for (const callback of onClear) { callback(); } }; waitForCaptureURIUnload(uri: string) { const start = Date.now(); const path = pathFromURI(uri); if (!path) { return Promise.resolve({ result: null, steps: [ { step: 'wait_for_capture_uri_unload', success: false, time: Date.now() - start, uri, }, ], }); } const getResult = () => ({ result: path, steps: [ { step: 'wait_for_capture_uri_unload', success: true, time: Date.now() - start, uri, }, ], }); const activeURI = this.activeURIs.get(uri); if (!activeURI) { return Promise.resolve(getResult()); } return new Promise(resolve => { const finish = () => resolve(getResult()); const newActiveURI = { ...activeURI, onClear: [...activeURI.onClear, finish], }; this.activeURIs.set(uri, newActiveURI); }); } setPendingThreadUpdateHandler = ( threadID: string, - pendingThreadUpdateHandler: ?(ThreadInfo) => mixed, + pendingThreadUpdateHandler: ?( + ThreadInfo | MinimallyEncodedThreadInfo, + ) => mixed, ) => { if (!pendingThreadUpdateHandler) { this.pendingThreadUpdateHandlers.delete(threadID); } else { this.pendingThreadUpdateHandlers.set( threadID, pendingThreadUpdateHandler, ); } }; render() { const inputState = this.inputStateSelector(this.state); return ( {this.props.children} ); } } const mediaCreationLoadingStatusSelector = createLoadingStatusSelector( sendMultimediaMessageActionTypes, ); const textCreationLoadingStatusSelector = createLoadingStatusSelector( sendTextMessageActionTypes, ); const ConnectedInputStateContainer: React.ComponentType = React.memo(function ConnectedInputStateContainer( props: BaseProps, ) { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const nextLocalID = useNextLocalID(); const messageStoreMessages = useSelector( state => state.messageStore.messages, ); const ongoingMessageCreation = useSelector( state => combineLoadingStatuses( mediaCreationLoadingStatusSelector(state), textCreationLoadingStatusSelector(state), ) === 'loading', ); const hasWiFi = useSelector(state => state.connectivity.hasWiFi); const calendarQuery = useCalendarQuery(); const callUploadMultimedia = useServerCall(uploadMultimedia); const callBlobServiceUpload = useBlobServiceUpload(); const callSendMultimediaMessage = useSendMultimediaMessage(); const callSendTextMessage = useSendTextMessage(); const callNewThread = useNewThread(); const dispatchActionPromise = useDispatchActionPromise(); const dispatch = useDispatch(); const mediaReportsEnabled = useIsReportEnabled('mediaReports'); const staffCanSee = useStaffCanSee(); const textMessageCreationSideEffectsFunc = useMessageCreationSideEffectsFunc(messageTypes.TEXT); return ( ); }); export default ConnectedInputStateContainer; diff --git a/native/input/input-state.js b/native/input/input-state.js index 4ea79e723..50f8abacc 100644 --- a/native/input/input-state.js +++ b/native/input/input-state.js @@ -1,70 +1,73 @@ // @flow import * as React from 'react'; import type { NativeMediaSelection } from 'lib/types/media-types.js'; import type { RawTextMessageInfo } from 'lib/types/messages/text.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; export type MultimediaProcessingStep = 'transcoding' | 'uploading'; export type PendingMultimediaUpload = { +failed: boolean, +progressPercent: number, +processingStep: ?MultimediaProcessingStep, }; export type MessagePendingUploads = { [localUploadID: string]: PendingMultimediaUpload, }; export type PendingMultimediaUploads = { [localMessageID: string]: MessagePendingUploads, }; export type EditInputBarMessageParameters = { +message: string, +mode: 'prepend' | 'replace', }; export type InputState = { +pendingUploads: PendingMultimediaUploads, +sendTextMessage: ( messageInfo: RawTextMessageInfo, - threadInfo: ThreadInfo, - parentThreadInfo: ?ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, ) => Promise, +sendMultimediaMessage: ( selections: $ReadOnlyArray, - threadInfo: ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, ) => Promise, +editInputMessage: (params: EditInputBarMessageParameters) => void, +addEditInputMessageListener: ( (params: EditInputBarMessageParameters) => void, ) => void, +removeEditInputMessageListener: ( (params: EditInputBarMessageParameters) => void, ) => void, +messageHasUploadFailure: (localMessageID: string) => boolean, +retryMessage: ( localMessageID: string, - threadInfo: ThreadInfo, - parentThreadInfo: ?ThreadInfo, + threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, + parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo, ) => Promise, +registerSendCallback: (() => void) => void, +unregisterSendCallback: (() => void) => void, +uploadInProgress: () => boolean, +reportURIDisplayed: (uri: string, loaded: boolean) => void, +setPendingThreadUpdateHandler: ( threadID: string, - pendingThreadUpdateHandler: ?(ThreadInfo) => mixed, + pendingThreadUpdateHandler: ?( + ThreadInfo | MinimallyEncodedThreadInfo, + ) => mixed, ) => void, +scrollToMessage: (messageKey: string) => void, +addScrollToMessageListener: ((messageKey: string) => void) => void, +removeScrollToMessageListener: ((messageKey: string) => void) => void, }; const InputStateContext: React.Context = React.createContext(null); export { InputStateContext }; diff --git a/native/keyboard/keyboard-state-container.react.js b/native/keyboard/keyboard-state-container.react.js index 46aef59ef..fca12f44d 100644 --- a/native/keyboard/keyboard-state-container.react.js +++ b/native/keyboard/keyboard-state-container.react.js @@ -1,147 +1,149 @@ // @flow import * as React from 'react'; import { Platform } from 'react-native'; import { KeyboardUtils } from 'react-native-keyboard-input'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import sleep from 'lib/utils/sleep.js'; import KeyboardInputHost from './keyboard-input-host.react.js'; import { KeyboardContext } from './keyboard-state.js'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from './keyboard.js'; import { tabBarAnimationDuration } from '../navigation/tab-bar.react.js'; import { waitForInteractions } from '../utils/timers.js'; type Props = { +children: React.Node, }; type State = { +systemKeyboardShowing: boolean, +mediaGalleryOpen: boolean, - +mediaGalleryThread: ?ThreadInfo, + +mediaGalleryThread: ?ThreadInfo | ?MinimallyEncodedThreadInfo, +renderKeyboardInputHost: boolean, }; class KeyboardStateContainer extends React.PureComponent { state: State = { systemKeyboardShowing: false, mediaGalleryOpen: false, mediaGalleryThread: null, renderKeyboardInputHost: false, }; keyboardShowListener: ?Object; keyboardDismissListener: ?Object; keyboardShow: () => void = () => { this.setState({ systemKeyboardShowing: true }); }; keyboardDismiss: () => void = () => { this.setState({ systemKeyboardShowing: false }); }; componentDidMount() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); } componentWillUnmount() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardDismissListener) { removeKeyboardListener(this.keyboardDismissListener); this.keyboardDismissListener = null; } } componentDidUpdate(prevProps: Props, prevState: State) { if (Platform.OS !== 'android') { return; } if (this.state.mediaGalleryOpen && !prevState.mediaGalleryOpen) { (async () => { await sleep(tabBarAnimationDuration); await waitForInteractions(); this.setState({ renderKeyboardInputHost: true }); })(); } } dismissKeyboard: () => void = () => { KeyboardUtils.dismiss(); this.hideMediaGallery(); }; dismissKeyboardIfShowing: () => boolean = () => { if (!this.keyboardShowing) { return false; } this.dismissKeyboard(); return true; }; get keyboardShowing(): boolean { const { systemKeyboardShowing, mediaGalleryOpen } = this.state; return systemKeyboardShowing || mediaGalleryOpen; } - showMediaGallery: (thread: ThreadInfo) => void = (thread: ThreadInfo) => { - this.setState({ - mediaGalleryOpen: true, - mediaGalleryThread: thread, - }); - }; + showMediaGallery: (thread: ThreadInfo | MinimallyEncodedThreadInfo) => void = + (thread: ThreadInfo | MinimallyEncodedThreadInfo) => { + this.setState({ + mediaGalleryOpen: true, + mediaGalleryThread: thread, + }); + }; hideMediaGallery: () => void = () => { this.setState({ mediaGalleryOpen: false, mediaGalleryThread: null, renderKeyboardInputHost: false, }); }; - getMediaGalleryThread: () => ?ThreadInfo = () => + getMediaGalleryThread: () => ?ThreadInfo | ?MinimallyEncodedThreadInfo = () => this.state.mediaGalleryThread; render(): React.Node { const { systemKeyboardShowing, mediaGalleryOpen, renderKeyboardInputHost } = this.state; const { keyboardShowing, dismissKeyboard, dismissKeyboardIfShowing, showMediaGallery, hideMediaGallery, getMediaGalleryThread, } = this; const keyboardState = { keyboardShowing, dismissKeyboard, dismissKeyboardIfShowing, systemKeyboardShowing, mediaGalleryOpen, showMediaGallery, hideMediaGallery, getMediaGalleryThread, }; const keyboardInputHost = renderKeyboardInputHost ? ( ) : null; return ( {this.props.children} {keyboardInputHost} ); } } export default KeyboardStateContainer; diff --git a/native/keyboard/keyboard-state.js b/native/keyboard/keyboard-state.js index 3ac97b881..396710158 100644 --- a/native/keyboard/keyboard-state.js +++ b/native/keyboard/keyboard-state.js @@ -1,21 +1,22 @@ // @flow import * as React from 'react'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; export type KeyboardState = { +keyboardShowing: boolean, +dismissKeyboard: () => void, +dismissKeyboardIfShowing: () => boolean, +systemKeyboardShowing: boolean, +mediaGalleryOpen: boolean, - +showMediaGallery: (thread: ThreadInfo) => void, + +showMediaGallery: (thread: ThreadInfo | MinimallyEncodedThreadInfo) => void, +hideMediaGallery: () => void, - +getMediaGalleryThread: () => ?ThreadInfo, + +getMediaGalleryThread: () => ?ThreadInfo | ?MinimallyEncodedThreadInfo, }; const KeyboardContext: React.Context = React.createContext(null); export { KeyboardContext }; diff --git a/native/media/chat-camera-modal.react.js b/native/media/chat-camera-modal.react.js index 70903e150..25d73c942 100644 --- a/native/media/chat-camera-modal.react.js +++ b/native/media/chat-camera-modal.react.js @@ -1,41 +1,42 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import type { PhotoCapture } from 'lib/types/media-types.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import CameraModal from './camera-modal.react.js'; import { InputStateContext } from '../input/input-state.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; export type ChatCameraModalParams = { +presentedFrom: string, - +thread: ThreadInfo, + +thread: ThreadInfo | MinimallyEncodedThreadInfo, }; type Props = { +navigation: AppNavigationProp<'ChatCameraModal'>, +route: NavigationRoute<'ChatCameraModal'>, }; function ChatCameraModal(props: Props): React.Node { const { navigation, route } = props; const { thread } = route.params; const inputState = React.useContext(InputStateContext); const sendPhoto = React.useCallback( (capture: PhotoCapture) => { invariant(inputState, 'inputState should be set'); inputState.sendMultimediaMessage([capture], thread); }, [inputState, thread], ); return ; } export default ChatCameraModal; diff --git a/native/search/message-search.react.js b/native/search/message-search.react.js index 4dc241119..f4a9c183f 100644 --- a/native/search/message-search.react.js +++ b/native/search/message-search.react.js @@ -1,226 +1,227 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { messageListData } from 'lib/selectors/chat-selectors.js'; import { createMessageInfo } from 'lib/shared/message-utils.js'; import { useSearchMessages, filterChatMessageInfosForSearch, } from 'lib/shared/search-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import SearchFooter from './search-footer.react.js'; import { MessageSearchContext } from './search-provider.react.js'; import { useHeightMeasurer } from '../chat/chat-context.js'; import type { ChatNavigationProp } from '../chat/chat.react.js'; import { MessageListContextProvider } from '../chat/message-list-types.js'; import MessageResult from '../chat/message-result.react.js'; import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; export type MessageSearchParams = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, }; export type MessageSearchProps = { +navigation: ChatNavigationProp<'MessageSearch'>, +route: NavigationRoute<'MessageSearch'>, }; function MessageSearch(props: MessageSearchProps): React.Node { const searchContext = React.useContext(MessageSearchContext); invariant(searchContext, 'searchContext should be set'); const { query, clearQuery } = searchContext; const { threadInfo } = props.route.params; React.useEffect(() => { return props.navigation.addListener('beforeRemove', clearQuery); }, [props.navigation, clearQuery]); const [lastID, setLastID] = React.useState(); const [searchResults, setSearchResults] = React.useState([]); const [endReached, setEndReached] = React.useState(false); const appendSearchResults = React.useCallback( ( newMessages: $ReadOnlyArray, end: boolean, queryID: number, ) => { if (queryID !== queryIDRef.current) { return; } setSearchResults(oldMessages => [...oldMessages, ...newMessages]); setEndReached(end); }, [], ); const searchMessages = useSearchMessages(); const queryIDRef = React.useRef(0); React.useEffect(() => { setSearchResults([]); setLastID(undefined); setEndReached(false); }, [query, searchMessages]); React.useEffect(() => { queryIDRef.current += 1; searchMessages( query, threadInfo.id, appendSearchResults, queryIDRef.current, lastID, ); }, [appendSearchResults, lastID, query, searchMessages, threadInfo.id]); const userInfos = useSelector(state => state.userStore.userInfos); const translatedSearchResults = React.useMemo(() => { const threadInfos = { [threadInfo.id]: threadInfo }; return searchResults .map(rawMessageInfo => createMessageInfo(rawMessageInfo, null, userInfos, threadInfos), ) .filter(Boolean); }, [searchResults, threadInfo, userInfos]); const chatMessageInfos = useSelector( messageListData(threadInfo.id, translatedSearchResults), ); const filteredChatMessageInfos = React.useMemo(() => { const result = filterChatMessageInfosForSearch( chatMessageInfos, translatedSearchResults, ); if (result && !endReached) { return [...result, { itemType: 'loader' }]; } return result; }, [chatMessageInfos, endReached, translatedSearchResults]); const [measuredMessages, setMeasuredMessages] = React.useState([]); const measureMessages = useHeightMeasurer(); const measureCallback = React.useCallback( (listDataWithHeights: $ReadOnlyArray) => { setMeasuredMessages(listDataWithHeights); }, [setMeasuredMessages], ); React.useEffect(() => { measureMessages(filteredChatMessageInfos, threadInfo, measureCallback); }, [filteredChatMessageInfos, measureCallback, measureMessages, threadInfo]); const [messageVerticalBounds, setMessageVerticalBounds] = React.useState(); const scrollViewContainerRef = React.useRef(); const onLayout = React.useCallback(() => { scrollViewContainerRef.current?.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } setMessageVerticalBounds({ height, y: pageY }); }, ); }, []); const renderItem = React.useCallback( ({ item }) => { if (item.itemType === 'loader') { return ; } return ( ); }, [messageVerticalBounds, props.navigation, props.route, threadInfo], ); const footer = React.useMemo(() => { if (query === '') { return ; } if (!endReached) { return null; } if (measuredMessages.length > 0) { return ; } const text = 'No results. Please try using different keywords to refine your search'; return ; }, [query, endReached, measuredMessages.length]); const onEndOfLoadedMessagesReached = React.useCallback(() => { if (endReached) { return; } setLastID(oldestMessageID(measuredMessages)); }, [endReached, measuredMessages, setLastID]); const styles = useStyles(unboundStyles); return ( ); } function oldestMessageID(data: $ReadOnlyArray) { for (let i = data.length - 1; i >= 0; i--) { if (data[i].itemType === 'message' && data[i].messageInfo.id) { return data[i].messageInfo.id; } } return undefined; } const unboundStyles = { content: { height: '100%', backgroundColor: 'panelBackground', }, }; export default MessageSearch; diff --git a/native/search/search-messages-button.react.js b/native/search/search-messages-button.react.js index a4b5acc8c..6e0630558 100644 --- a/native/search/search-messages-button.react.js +++ b/native/search/search-messages-button.react.js @@ -1,44 +1,45 @@ // @flow import * as React from 'react'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import type { ChatNavigationProp } from '../chat/chat.react.js'; import Button from '../components/button.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { MessageSearchRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; type Props = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +navigate: $PropertyType, 'navigate'>, }; function SearchMessagesButton(props: Props): React.Node { const { threadInfo, navigate } = props; const styles = useStyles(unboundStyles); const onPress = React.useCallback(() => { navigate<'MessageSearch'>({ name: MessageSearchRouteName, params: { threadInfo }, key: `${MessageSearchRouteName}${threadInfo.id}`, }); }, [navigate, threadInfo]); return ( ); } const unboundStyles = { button: { color: 'panelForegroundLabel', paddingHorizontal: 10, }, }; export default SearchMessagesButton; diff --git a/native/types/chat-types.js b/native/types/chat-types.js index ccfabb4b8..e7d5a8083 100644 --- a/native/types/chat-types.js +++ b/native/types/chat-types.js @@ -1,78 +1,79 @@ // @flow import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import type { LocalMessageInfo, MultimediaMessageInfo, RobotextMessageInfo, } from 'lib/types/message-types.js'; import type { TextMessageInfo } from 'lib/types/messages/text.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import type { EntityText } from 'lib/utils/entity-text.js'; import type { MessagePendingUploads } from '../input/input-state.js'; export type ChatRobotextMessageInfoItemWithHeight = { +itemType: 'message', +messageShapeType: 'robotext', +messageInfo: RobotextMessageInfo, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, +robotext: EntityText, - +threadCreatedFromMessage: ?ThreadInfo, + +threadCreatedFromMessage: ?ThreadInfo | ?MinimallyEncodedThreadInfo, +contentHeight: number, +reactions: ReactionInfo, }; export type ChatTextMessageInfoItemWithHeight = { +itemType: 'message', +messageShapeType: 'text', +messageInfo: TextMessageInfo, +localMessageInfo: ?LocalMessageInfo, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, +contentHeight: number, - +threadCreatedFromMessage: ?ThreadInfo, + +threadCreatedFromMessage: ?ThreadInfo | ?MinimallyEncodedThreadInfo, +reactions: ReactionInfo, +hasBeenEdited: ?boolean, +isPinned: ?boolean, }; // We "measure" the contentHeight of a multimedia message using the media // dimensions. This means for multimedia messages we only need to actually // measure the inline engagement node export type MultimediaContentSizes = { +imageHeight: number, +contentHeight: number, +contentWidth: number, }; export type ChatMultimediaMessageInfoItem = { ...MultimediaContentSizes, +itemType: 'message', +messageShapeType: 'multimedia', +messageInfo: MultimediaMessageInfo, +localMessageInfo: ?LocalMessageInfo, - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, - +threadCreatedFromMessage: ?ThreadInfo, + +threadCreatedFromMessage: ?ThreadInfo | ?MinimallyEncodedThreadInfo, +pendingUploads: ?MessagePendingUploads, +reactions: ReactionInfo, +hasBeenEdited: ?boolean, +isPinned: ?boolean, +inlineEngagementHeight: ?number, }; export type ChatMessageInfoItemWithHeight = | ChatRobotextMessageInfoItemWithHeight | ChatTextMessageInfoItemWithHeight | ChatMultimediaMessageInfoItem; export type ChatMessageItemWithHeight = | { itemType: 'loader' } | ChatMessageInfoItemWithHeight; diff --git a/native/utils/drawer-utils.react.js b/native/utils/drawer-utils.react.js index 0bb024617..a8e95571d 100644 --- a/native/utils/drawer-utils.react.js +++ b/native/utils/drawer-utils.react.js @@ -1,118 +1,119 @@ // @flow +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypeIsCommunityRoot } from 'lib/types/thread-types-enum.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import type { CommunityDrawerItemData } from 'lib/utils/drawer-utils.react.js'; import type { TextStyle } from '../types/styles.js'; export type CommunityDrawerItemDataFlattened = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +hasSubchannelsButton: boolean, +labelStyle: TextStyle, +hasChildren: boolean, +itemStyle: { +indentation: number, +background: 'none' | 'beginning' | 'middle' | 'end', }, }; const defaultIndentation = 8; const addedIndentation = 16; function flattenDrawerItemsData( data: $ReadOnlyArray>, expanded: $ReadOnlyArray, prevIndentation: ?number, ): $ReadOnlyArray { let results = []; for (const item of data) { const isOpen = expanded.includes(item.threadInfo.id); const isCommunity = threadTypeIsCommunityRoot(item.threadInfo.type); let background = 'middle'; if (isCommunity) { background = isOpen ? 'beginning' : 'none'; } let indentation = defaultIndentation; if (!isCommunity && prevIndentation) { indentation = prevIndentation + addedIndentation; } results.push({ threadInfo: item.threadInfo, hasSubchannelsButton: item.hasSubchannelsButton, labelStyle: item.labelStyle, hasChildren: item.itemChildren?.length > 0, itemStyle: { indentation, background, }, }); if (!isOpen) { continue; } results = results.concat( flattenDrawerItemsData(item.itemChildren, expanded, indentation), ); if (isCommunity) { results[results.length - 1] = { ...results[results.length - 1], itemStyle: { ...results[results.length - 1].itemStyle, background: 'end', }, }; } } return results; } function findAllDescendantIDs( data: $ReadOnlyArray>, ): $ReadOnlyArray { const results = []; for (const item of data) { results.push(item.threadInfo.id); results.concat(findAllDescendantIDs(item.itemChildren)); } return results; } function findThreadChildrenItems( data: $ReadOnlyArray>, id: string, ): ?$ReadOnlyArray> { for (const item of data) { if (item.threadInfo.id === id) { return item.itemChildren; } const result = findThreadChildrenItems(item.itemChildren, id); if (result) { return result; } } return undefined; } function filterOutThreadAndDescendantIDs( idsToFilter: $ReadOnlyArray, allItems: $ReadOnlyArray>, threadID: string, ): $ReadOnlyArray { const childItems = findThreadChildrenItems(allItems, threadID); if (!childItems) { return []; } const descendants = findAllDescendantIDs(childItems); const descendantsSet = new Set(descendants); return idsToFilter.filter( item => !descendantsSet.has(item) && item !== threadID, ); } export { flattenDrawerItemsData, filterOutThreadAndDescendantIDs }; diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js index 57c0b5f81..4893031a0 100644 --- a/web/chat/chat-input-bar.react.js +++ b/web/chat/chat-input-bar.react.js @@ -1,687 +1,688 @@ // @flow import invariant from 'invariant'; import _difference from 'lodash/fp/difference.js'; import * as React from 'react'; import { joinThreadActionTypes, useJoinThread, newThreadActionTypes, } from 'lib/actions/thread-actions.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { useChatMentionContext, useThreadChatMentionCandidates, } from 'lib/hooks/chat-mention-hooks.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userStoreMentionSearchIndex } from 'lib/selectors/user-selectors.js'; import { getMentionTypeaheadUserSuggestions, getTypeaheadRegexMatches, getUserMentionsCandidates, getMentionTypeaheadChatSuggestions, type MentionTypeaheadSuggestionItem, type TypeaheadMatchedStrings, } from 'lib/shared/mention-utils.js'; import { localIDPrefix, trimMessage } from 'lib/shared/message-utils.js'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, checkIfDefaultMembersAreVoiced, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; +import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadInfo, type ClientThreadJoinRequest, type ThreadJoinPayload, } from 'lib/types/thread-types.js'; import { type UserInfos } from 'lib/types/user-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import css from './chat-input-bar.css'; import TypeaheadTooltip from './typeahead-tooltip.react.js'; import Button from '../components/button.react.js'; import { type InputState, type PendingMultimediaUpload, } from '../input/input-state.js'; import LoadingIndicator from '../loading-indicator.react.js'; import { allowedMimeTypeString } from '../media/file-utils.js'; import Multimedia from '../media/multimedia.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js'; import { webMentionTypeaheadRegex, getMentionTypeaheadTooltipActions, getMentionTypeaheadTooltipButtons, } from '../utils/typeahead-utils.js'; type BaseProps = { - +threadInfo: ThreadInfo, + +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +inputState: InputState, }; type Props = { ...BaseProps, +viewerID: ?string, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +isThreadActive: boolean, +userInfos: UserInfos, +dispatchActionPromise: DispatchActionPromise, +joinThread: (request: ClientThreadJoinRequest) => Promise, +typeaheadMatchedStrings: ?TypeaheadMatchedStrings, +suggestions: $ReadOnlyArray, +parentThreadInfo: ?ThreadInfo, }; class ChatInputBar extends React.PureComponent { textarea: ?HTMLTextAreaElement; multimediaInput: ?HTMLInputElement; componentDidMount() { this.updateHeight(); if (this.props.isThreadActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isThreadActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props) { if (this.props.isThreadActive && !prevProps.isThreadActive) { this.addReplyListener(); } else if (!this.props.isThreadActive && prevProps.isThreadActive) { this.removeReplyListener(); } const { inputState } = this.props; const prevInputState = prevProps.inputState; if (inputState.draft !== prevInputState.draft) { this.updateHeight(); } if ( inputState.draft !== prevInputState.draft || inputState.textCursorPosition !== prevInputState.textCursorPosition ) { inputState.setTypeaheadState({ canBeVisible: true, }); } const curUploadIDs = ChatInputBar.unassignedUploadIDs( inputState.pendingUploads, ); const prevUploadIDs = ChatInputBar.unassignedUploadIDs( prevInputState.pendingUploads, ); if ( this.multimediaInput && _difference(prevUploadIDs)(curUploadIDs).length > 0 ) { // Whenever a pending upload is removed, we reset the file // HTMLInputElement's value field, so that if the same upload occurs again // the onChange call doesn't get filtered this.multimediaInput.value = ''; } else if ( this.textarea && _difference(curUploadIDs)(prevUploadIDs).length > 0 ) { // Whenever a pending upload is added, we focus the textarea this.textarea.focus(); return; } if ( (this.props.threadInfo.id !== prevProps.threadInfo.id || (inputState.textCursorPosition !== prevInputState.textCursorPosition && this.textarea?.selectionStart === this.textarea?.selectionEnd)) && this.textarea ) { this.textarea.focus(); this.textarea?.setSelectionRange( inputState.textCursorPosition, inputState.textCursorPosition, 'none', ); } } static unassignedUploadIDs( pendingUploads: $ReadOnlyArray, ) { return pendingUploads .filter( (pendingUpload: PendingMultimediaUpload) => !pendingUpload.messageID, ) .map((pendingUpload: PendingMultimediaUpload) => pendingUpload.localID); } updateHeight() { const textarea = this.textarea; if (textarea) { textarea.style.height = 'auto'; const newHeight = Math.min(textarea.scrollHeight, 150); textarea.style.height = `${newHeight}px`; } } addReplyListener() { invariant( this.props.inputState, 'inputState should be set in addReplyListener', ); this.props.inputState.addReplyListener(this.focusAndUpdateText); } removeReplyListener() { invariant( this.props.inputState, 'inputState should be set in removeReplyListener', ); this.props.inputState.removeReplyListener(this.focusAndUpdateText); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; if (!isMember && canJoin && !this.props.threadCreationInProgress) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { buttonContent = ( <>

Join Chat

); } joinButton = (
); } const { pendingUploads, cancelPendingUpload } = this.props.inputState; const multimediaPreviews = pendingUploads.map(pendingUpload => { const { uri, mediaType, thumbHash, dimensions } = pendingUpload; let mediaSource = { thumbHash, dimensions }; if (mediaType !== 'encrypted_photo' && mediaType !== 'encrypted_video') { mediaSource = { ...mediaSource, type: mediaType, uri, thumbnailURI: null, }; } else { const { encryptionKey } = pendingUpload; invariant( encryptionKey, 'encryptionKey should be set for encrypted media', ); mediaSource = { ...mediaSource, type: mediaType, blobURI: uri, encryptionKey, thumbnailBlobURI: null, thumbnailEncryptionKey: null, }; } return ( ); }); const previews = multimediaPreviews.length > 0 ? (
{multimediaPreviews}
) : null; let content; // If the thread is created by somebody else while the viewer is attempting // to create it, the threadInfo might be modified in-place and won't // list the viewer as a member, which will end up hiding the input. In // this case, we will assume that our creation action will get translated, // into a join and as long as members are voiced, we can show the input. const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); let sendButton; if (this.props.inputState.draft.length) { sendButton = ( ); } if ( threadHasPermission(this.props.threadInfo, threadPermissions.VOICED) || (this.props.threadCreationInProgress && defaultMembersAreVoiced) ) { content = (