diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index bb4afb9d1..c579440a8 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,710 +1,727 @@ // @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 ThreadInfo } from '../types/thread-types.js'; +import type { 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, ): 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, 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, 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 ); } -function isInvalidPinSource(message: RawMessageInfo | MessageInfo): boolean { - return !messageSpecs[message.type].canBePinned; +// 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: ThreadInfo, +): 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/utils/toggle-pin-utils.js b/lib/utils/toggle-pin-utils.js new file mode 100644 index 000000000..59ba2ca8d --- /dev/null +++ b/lib/utils/toggle-pin-utils.js @@ -0,0 +1,25 @@ +// @flow + +import { isInvalidPinSourceForThread } from '../shared/message-utils.js'; +import { threadHasPermission } from '../shared/thread-utils.js'; +import type { + ComposableMessageInfo, + RobotextMessageInfo, +} from '../types/message-types.js'; +import { threadPermissions } from '../types/thread-permission-types.js'; +import type { ThreadInfo } from '../types/thread-types.js'; + +function canToggleMessagePin( + messageInfo: ComposableMessageInfo | RobotextMessageInfo, + threadInfo: ThreadInfo, +): boolean { + const isValidMessage = !isInvalidPinSourceForThread(messageInfo, threadInfo); + const hasManagePinsPermission = threadHasPermission( + threadInfo, + threadPermissions.MANAGE_PINS, + ); + + return isValidMessage && hasManagePinsPermission; +} + +export { canToggleMessagePin }; diff --git a/native/chat/message-results-screen.react.js b/native/chat/message-results-screen.react.js index c3817d0a9..b97f932c4 100644 --- a/native/chat/message-results-screen.react.js +++ b/native/chat/message-results-screen.react.js @@ -1,181 +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 { fetchPinnedMessages } from 'lib/actions/message-actions.js'; import { messageListData } from 'lib/selectors/chat-selectors.js'; import { createMessageInfo, - isInvalidPinSource, + isInvalidPinSourceForThread, } from 'lib/shared/message-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useServerCall } from 'lib/utils/action-utils.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, }; 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 = useServerCall(fetchPinnedMessages); 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 && - !isInvalidPinSource(item.messageInfo), + !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]); + }, [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/message.react.js b/native/chat/message.react.js index 8bd887b36..c82675764 100644 --- a/native/chat/message.react.js +++ b/native/chat/message.react.js @@ -1,153 +1,162 @@ // @flow import * as React from 'react'; import { LayoutAnimation, TouchableWithoutFeedback, PixelRatio, } from 'react-native'; import { messageKey } from 'lib/shared/message-utils.js'; +import { canToggleMessagePin } from 'lib/utils/toggle-pin-utils.js'; import type { ChatNavigationProp } from './chat.react.js'; import MultimediaMessage from './multimedia-message.react.js'; import { RobotextMessage } from './robotext-message.react.js'; import { TextMessage } from './text-message.react.js'; import { messageItemHeight } from './utils.js'; import { KeyboardContext } from '../keyboard/keyboard-state.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import type { NavigationRoute } from '../navigation/route-names.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; import { type VerticalBounds } from '../types/layout-types.js'; import type { LayoutEvent } from '../types/react-native.js'; type Props = { +item: ChatMessageInfoItemWithHeight, +focused: boolean, +navigation: | ChatNavigationProp<'MessageList'> | AppNavigationProp<'TogglePinModal'> | ChatNavigationProp<'MessageResultsScreen'> | ChatNavigationProp<'MessageSearch'>, +route: | NavigationRoute<'MessageList'> | NavigationRoute<'TogglePinModal'> | NavigationRoute<'MessageResultsScreen'> | NavigationRoute<'MessageSearch'>, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, shouldDisplayPinIndicator: boolean, }; function Message(props: Props): React.Node { const { focused, item, navigation, route, toggleFocus, verticalBounds, shouldDisplayPinIndicator, } = props; const focusedOrStartsConversation = focused || item.startsConversation; React.useEffect(() => { LayoutAnimation.easeInEaseOut(); }, [focusedOrStartsConversation]); const keyboardState = React.useContext(KeyboardContext); const dismissKeyboard = keyboardState?.dismissKeyboard; const onMessagePress = React.useCallback( () => dismissKeyboard?.(), [dismissKeyboard], ); const onLayout = React.useCallback( (event: LayoutEvent) => { if (focused) { return; } const measuredHeight = event.nativeEvent.layout.height; const expectedHeight = messageItemHeight(item); const pixelRatio = 1 / PixelRatio.get(); const distance = Math.abs(measuredHeight - expectedHeight); if (distance < pixelRatio) { return; } const approxMeasuredHeight = Math.round(measuredHeight * 100) / 100; const approxExpectedHeight = Math.round(expectedHeight * 100) / 100; console.log( `Message height for ${item.messageShapeType} ` + `${messageKey(item.messageInfo)} was expected to be ` + `${approxExpectedHeight} but is actually ${approxMeasuredHeight}. ` + "This means MessageList's FlatList isn't getting the right item " + 'height for some of its nodes, which is guaranteed to cause glitchy ' + 'behavior. Please investigate!!', ); }, [focused, item], ); + const canTogglePins = React.useMemo( + () => canToggleMessagePin(props.item.messageInfo, props.item.threadInfo), + [props.item.messageInfo, props.item.threadInfo], + ); + const innerMessageNode = React.useMemo(() => { if (item.messageShapeType === 'text') { return ( ); } else if (item.messageShapeType === 'multimedia') { return ( ); } else { return ( ); } }, [ focused, item, navigation, route, shouldDisplayPinIndicator, toggleFocus, verticalBounds, + canTogglePins, ]); const message = React.useMemo( () => ( {innerMessageNode} ), [innerMessageNode, onLayout, onMessagePress], ); return message; } export default Message; diff --git a/native/chat/multimedia-message.react.js b/native/chat/multimedia-message.react.js index 7cea6cde2..c64913281 100644 --- a/native/chat/multimedia-message.react.js +++ b/native/chat/multimedia-message.react.js @@ -1,266 +1,254 @@ // @flow import type { LeafRoute, NavigationProp, ParamListBase, } from '@react-navigation/native'; import { useNavigation, useRoute } from '@react-navigation/native'; import * as React from 'react'; import { View } from 'react-native'; -import { isInvalidPinSource, messageKey } from 'lib/shared/message-utils.js'; -import { - threadHasPermission, - useCanCreateSidebarFromMessage, -} from 'lib/shared/thread-utils.js'; +import { messageKey } from 'lib/shared/message-utils.js'; +import { useCanCreateSidebarFromMessage } from 'lib/shared/thread-utils.js'; import type { MediaInfo } from 'lib/types/media-types.js'; -import { threadPermissions } from 'lib/types/thread-permission-types.js'; import ComposedMessage from './composed-message.react.js'; import { InnerMultimediaMessage } from './inner-multimedia-message.react.js'; import { getMediaKey, multimediaMessageSendFailed, } from './multimedia-message-utils.js'; import { getMessageTooltipKey } from './utils.js'; import { ChatContext, type ChatContextType } from '../chat/chat-context.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import type { OverlayContextType } from '../navigation/overlay-context.js'; import { ImageModalRouteName, MultimediaMessageTooltipModalRouteName, VideoPlaybackModalRouteName, } from '../navigation/route-names.js'; import { fixedTooltipHeight } from '../tooltip/tooltip.react.js'; import type { ChatMultimediaMessageInfoItem } from '../types/chat-types.js'; import type { VerticalBounds, LayoutCoordinates, } from '../types/layout-types.js'; type BaseProps = { ...React.ElementConfig, +item: ChatMultimediaMessageInfoItem, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, + +canTogglePins: boolean, +shouldDisplayPinIndicator: boolean, }; type Props = { ...BaseProps, +navigation: NavigationProp, +route: LeafRoute<>, +overlayContext: ?OverlayContextType, +chatContext: ?ChatContextType, +canCreateSidebarFromMessage: boolean, - +canTogglePins: boolean, }; type State = { +clickable: boolean, }; class MultimediaMessage extends React.PureComponent { state: State = { clickable: true, }; view: ?React.ElementRef; setClickable = (clickable: boolean) => { this.setState({ clickable }); }; onPressMultimedia = ( mediaInfo: MediaInfo, initialCoordinates: LayoutCoordinates, ) => { const { navigation, item, route, verticalBounds } = this.props; navigation.navigate<'VideoPlaybackModal' | 'ImageModal'>({ name: mediaInfo.type === 'video' || mediaInfo.type === 'encrypted_video' ? VideoPlaybackModalRouteName : ImageModalRouteName, key: getMediaKey(item, mediaInfo), params: { presentedFrom: route.key, mediaInfo, item, initialCoordinates, verticalBounds, }, }); }; visibleEntryIDs() { const result = []; if (this.props.canTogglePins) { this.props.item.isPinned ? result.push('unpin') : result.push('pin'); } if ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ) { result.push('sidebar'); } if (!this.props.item.messageInfo.creator.isViewer) { result.push('report'); } return result; } onLayout = () => {}; viewRef = (view: ?React.ElementRef) => { this.view = view; }; onLongPress = () => { const visibleEntryIDs = this.visibleEntryIDs(); if (visibleEntryIDs.length === 0) { return; } const { view, props: { verticalBounds }, } = this; if (!view || !verticalBounds) { return; } if (!this.state.clickable) { return; } this.setClickable(false); const { item } = this.props; if (!this.props.focused) { this.props.toggleFocus(messageKey(item.messageInfo)); } this.props.overlayContext?.setScrollBlockingModalStatus('open'); view.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const multimediaTop = pageY; const multimediaBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = fixedTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = fixedTooltipHeight + aboveMargin; let margin = belowMargin; if ( multimediaBottom + belowSpace > boundsBottom && multimediaTop - aboveSpace > boundsTop ) { margin = aboveMargin; } const currentInputBarHeight = this.props.chatContext?.chatInputBarHeights.get(item.threadInfo.id) ?? 0; this.props.navigation.navigate<'MultimediaMessageTooltipModal'>({ name: MultimediaMessageTooltipModalRouteName, params: { presentedFrom: this.props.route.key, item, initialCoordinates: coordinates, verticalBounds, tooltipLocation: 'fixed', margin, visibleEntryIDs, chatInputBarHeight: currentInputBarHeight, }, key: getMessageTooltipKey(item), }); }); }; canNavigateToSidebar() { return ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ); } render() { const { item, focused, toggleFocus, verticalBounds, shouldDisplayPinIndicator, navigation, route, overlayContext, chatContext, canCreateSidebarFromMessage, canTogglePins, ...viewProps } = this.props; return ( ); } } const ConnectedMultimediaMessage: React.ComponentType = React.memo(function ConnectedMultimediaMessage(props: BaseProps) { const navigation = useNavigation(); const route = useRoute(); const overlayContext = React.useContext(OverlayContext); const chatContext = React.useContext(ChatContext); const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( props.item.threadInfo, props.item.messageInfo, ); - const canTogglePins = - !isInvalidPinSource(props.item.messageInfo) && - threadHasPermission( - props.item.threadInfo, - threadPermissions.MANAGE_PINS, - ) && - props.item.threadInfo.sourceMessageID !== props.item.messageInfo.id; return ( ); }); export default ConnectedMultimediaMessage; diff --git a/native/chat/text-message.react.js b/native/chat/text-message.react.js index e5cf12c85..d4420e6d1 100644 --- a/native/chat/text-message.react.js +++ b/native/chat/text-message.react.js @@ -1,318 +1,309 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; -import { isInvalidPinSource, messageKey } from 'lib/shared/message-utils.js'; +import { messageKey } from 'lib/shared/message-utils.js'; import { threadHasPermission, useCanCreateSidebarFromMessage, } from 'lib/shared/thread-utils.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { ChatNavigationProp } from './chat.react.js'; import ComposedMessage from './composed-message.react.js'; import { InnerTextMessage } from './inner-text-message.react.js'; import { MessageEditingContext } from './message-editing-context.react.js'; import { MessagePressResponderContext, type MessagePressResponderContextType, } from './message-press-responder-context.js'; import textMessageSendFailed from './text-message-send-failed.js'; import { getMessageTooltipKey } from './utils.js'; import { ChatContext, type ChatContextType } from '../chat/chat-context.js'; import { MarkdownContext } from '../markdown/markdown-context.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { useCanEditMessageNative } from '../navigation/nav-selectors.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import { TextMessageTooltipModalRouteName } from '../navigation/route-names.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { fixedTooltipHeight } from '../tooltip/tooltip.react.js'; import type { ChatTextMessageInfoItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; type BaseProps = { ...React.ElementConfig, +item: ChatTextMessageInfoItemWithHeight, +navigation: | ChatNavigationProp<'MessageList'> | AppNavigationProp<'TogglePinModal'> | ChatNavigationProp<'MessageResultsScreen'> | ChatNavigationProp<'MessageSearch'>, +route: | NavigationRoute<'MessageList'> | NavigationRoute<'TogglePinModal'> | NavigationRoute<'MessageResultsScreen'> | NavigationRoute<'MessageSearch'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, + +canTogglePins: boolean, +shouldDisplayPinIndicator: boolean, }; type Props = { ...BaseProps, // Redux state +canCreateSidebarFromMessage: boolean, // withOverlayContext +overlayContext: ?OverlayContextType, // ChatContext +chatContext: ?ChatContextType, // MarkdownContext +isLinkModalActive: boolean, +isUserProfileBottomSheetActive: boolean, +canEditMessage: boolean, - +canTogglePins: boolean, }; class TextMessage extends React.PureComponent { message: ?React.ElementRef; messagePressResponderContext: MessagePressResponderContextType; constructor(props: Props) { super(props); this.messagePressResponderContext = { onPressMessage: this.onPress, }; } render() { const { item, navigation, route, focused, toggleFocus, verticalBounds, shouldDisplayPinIndicator, overlayContext, chatContext, isLinkModalActive, isUserProfileBottomSheetActive, canCreateSidebarFromMessage, canEditMessage, canTogglePins, ...viewProps } = this.props; let swipeOptions = 'none'; const canReply = this.canReply(); const canNavigateToSidebar = this.canNavigateToSidebar(); if (isLinkModalActive) { swipeOptions = 'none'; } else if (canReply && canNavigateToSidebar) { swipeOptions = 'both'; } else if (canReply) { swipeOptions = 'reply'; } else if (canNavigateToSidebar) { swipeOptions = 'sidebar'; } return ( ); } messageRef = (message: ?React.ElementRef) => { this.message = message; }; canReply() { return threadHasPermission( this.props.item.threadInfo, threadPermissions.VOICED, ); } canNavigateToSidebar() { return ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ); } visibleEntryIDs() { const result = ['copy']; if (this.canReply()) { result.push('reply'); } if (this.props.canEditMessage) { result.push('edit'); } if (this.props.canTogglePins) { this.props.item.isPinned ? result.push('unpin') : result.push('pin'); } if ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ) { result.push('sidebar'); } if (!this.props.item.messageInfo.creator.isViewer) { result.push('report'); } return result; } onPress = () => { const visibleEntryIDs = this.visibleEntryIDs(); if (visibleEntryIDs.length === 0) { return; } const { message, props: { verticalBounds, isLinkModalActive, isUserProfileBottomSheetActive, }, } = this; if ( !message || !verticalBounds || isLinkModalActive || isUserProfileBottomSheetActive ) { return; } const { focused, toggleFocus, item } = this.props; if (!focused) { toggleFocus(messageKey(item.messageInfo)); } const { overlayContext } = this.props; invariant(overlayContext, 'TextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); message.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const messageTop = pageY; const messageBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = fixedTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = fixedTooltipHeight + aboveMargin; let margin = belowMargin; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { margin = aboveMargin; } const currentInputBarHeight = this.props.chatContext?.chatInputBarHeights.get(item.threadInfo.id) ?? 0; this.props.navigation.navigate<'TextMessageTooltipModal'>({ name: TextMessageTooltipModalRouteName, params: { presentedFrom: this.props.route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs, tooltipLocation: 'fixed', margin, item, chatInputBarHeight: currentInputBarHeight, }, key: getMessageTooltipKey(item), }); }); }; } const ConnectedTextMessage: React.ComponentType = React.memo(function ConnectedTextMessage(props: BaseProps) { const overlayContext = React.useContext(OverlayContext); const chatContext = React.useContext(ChatContext); const markdownContext = React.useContext(MarkdownContext); invariant(markdownContext, 'markdownContext should be set'); const { linkModalActive, userProfileBottomSheetActive, clearMarkdownContextData, } = markdownContext; const key = messageKey(props.item.messageInfo); // We check if there is an key in the object - if not, we // default to false. The likely situation where the former statement // evaluates to null is when the thread is opened for the first time. const isLinkModalActive = linkModalActive[key] ?? false; const isUserProfileBottomSheetActive = userProfileBottomSheetActive[key] ?? false; const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( props.item.threadInfo, props.item.messageInfo, ); const messageEditingContext = React.useContext(MessageEditingContext); const editMessageID = messageEditingContext?.editState.editedMessage?.id; const isThisMessageEdited = editMessageID === props.item.messageInfo.id; const canEditMessage = useCanEditMessageNative(props.item.threadInfo, props.item.messageInfo) && !isThisMessageEdited; - const canTogglePins = - !isInvalidPinSource(props.item.messageInfo) && - threadHasPermission( - props.item.threadInfo, - threadPermissions.MANAGE_PINS, - ) && - props.item.threadInfo.sourceMessageID !== props.item.messageInfo.id; - React.useEffect(() => clearMarkdownContextData, [clearMarkdownContextData]); return ( ); }); export { ConnectedTextMessage as TextMessage }; diff --git a/web/modals/chat/message-results-modal.react.js b/web/modals/chat/message-results-modal.react.js index 8266086c3..0fe3ef393 100644 --- a/web/modals/chat/message-results-modal.react.js +++ b/web/modals/chat/message-results-modal.react.js @@ -1,152 +1,152 @@ // @flow import * as React from 'react'; import { fetchPinnedMessages, fetchPinnedMessageActionTypes, } from 'lib/actions/message-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { messageListData } from 'lib/selectors/chat-selectors.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { createMessageInfo, - isInvalidPinSource, + isInvalidPinSourceForThread, modifyItemForResultScreen, } from 'lib/shared/message-utils.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import css from './message-results-modal.css'; import MessageResult from '../../components/message-result.react.js'; import LoadingIndicator from '../../loading-indicator.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import Modal from '../modal.react.js'; type MessageResultsModalProps = { +threadInfo: ThreadInfo, +modalName: string, }; const loadingStatusSelector = createLoadingStatusSelector( fetchPinnedMessageActionTypes, ); function MessageResultsModal(props: MessageResultsModalProps): React.Node { const { threadInfo, modalName } = props; const { id: threadID } = threadInfo; const { popModal } = useModalContext(); const [rawMessageResults, setRawMessageResults] = React.useState([]); const callFetchPinnedMessages = useServerCall(fetchPinnedMessages); const dispatchActionPromise = useDispatchActionPromise(); const userInfos = useSelector(state => state.userStore.userInfos); const loadingStatus = useSelector(loadingStatusSelector); React.useEffect(() => { dispatchActionPromise( fetchPinnedMessageActionTypes, (async () => { const result = await callFetchPinnedMessages({ threadID }); setRawMessageResults(result.pinnedMessages); })(), ); }, [dispatchActionPromise, 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 && - !isInvalidPinSource(item.messageInfo), + !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; - }, [chatMessageInfos, rawMessageResults]); + }, [chatMessageInfos, rawMessageResults, threadInfo]); const modifiedItems = React.useMemo( () => sortedUniqueChatMessageInfoItems .filter(Boolean) .map(item => modifyItemForResultScreen(item)), [sortedUniqueChatMessageInfoItems], ); const messageResultsToDisplay = React.useMemo(() => { const items = modifiedItems.map(item => ( )); return <>{items}; }, [modifiedItems, threadInfo]); const loadingIndicator = React.useMemo(() => { if (loadingStatus === 'loading') { return (
); } return null; }, [loadingStatus]); return (
{loadingIndicator}
{messageResultsToDisplay}
); } export default MessageResultsModal; diff --git a/web/utils/tooltip-action-utils.js b/web/utils/tooltip-action-utils.js index 1e43f84e0..537de1a4e 100644 --- a/web/utils/tooltip-action-utils.js +++ b/web/utils/tooltip-action-utils.js @@ -1,490 +1,485 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { useResettingState } from 'lib/hooks/use-resetting-state.js'; import type { ReactionInfo, ChatMessageInfoItem, } from 'lib/selectors/chat-selectors.js'; import { useCanEditMessage } from 'lib/shared/edit-messages-utils.js'; -import { - createMessageReply, - isInvalidPinSource, -} from 'lib/shared/message-utils.js'; +import { createMessageReply } from 'lib/shared/message-utils.js'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js'; import { threadHasPermission, useSidebarExistsOrCanBeCreated, } from 'lib/shared/thread-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { longAbsoluteDate } from 'lib/utils/date-utils.js'; +import { canToggleMessagePin } from 'lib/utils/toggle-pin-utils.js'; import { type MessageTooltipAction, getTooltipPositionStyle, calculateMessageTooltipSize, calculateReactionTooltipSize, type TooltipPosition, type TooltipPositionStyle, type TooltipSize, } from './tooltip-utils.js'; import { getComposedMessageID } from '../chat/chat-constants.js'; import { useEditModalContext } from '../chat/edit-message-provider.js'; import MessageTooltip from '../chat/message-tooltip.react.js'; import ReactionTooltip from '../chat/reaction-tooltip.react.js'; import { useTooltipContext } from '../chat/tooltip-provider.js'; import CommIcon from '../CommIcon.react.js'; import { InputStateContext } from '../input/input-state.js'; import TogglePinModal from '../modals/chat/toggle-pin-modal.react.js'; import { useOnClickPendingSidebar, useOnClickThread, } from '../selectors/thread-selectors.js'; type UseTooltipArgs = { +createTooltip: (tooltipPositionStyle: TooltipPositionStyle) => React.Node, +tooltipSize: TooltipSize, +availablePositions: $ReadOnlyArray, }; type UseTooltipResult = { +onMouseEnter: (event: SyntheticEvent) => mixed, +onMouseLeave: ?() => mixed, }; function useTooltip({ createTooltip, tooltipSize, availablePositions, }: UseTooltipArgs): UseTooltipResult { const [onMouseLeave, setOnMouseLeave] = React.useState mixed>(null); const [tooltipSourcePosition, setTooltipSourcePosition] = React.useState(); const { renderTooltip } = useTooltipContext(); const updateTooltip = React.useRef(); const onMouseEnter = React.useCallback( (event: SyntheticEvent) => { if (!renderTooltip) { return; } const rect = event.currentTarget.getBoundingClientRect(); const { top, bottom, left, right, height, width } = rect; const sourcePosition = { top, bottom, left, right, height, width }; setTooltipSourcePosition(sourcePosition); const tooltipPositionStyle = getTooltipPositionStyle({ tooltipSourcePosition: sourcePosition, tooltipSize, availablePositions, }); if (!tooltipPositionStyle) { return; } const tooltip = createTooltip(tooltipPositionStyle); const renderTooltipResult = renderTooltip({ newNode: tooltip, tooltipPositionStyle, }); if (renderTooltipResult) { const { onMouseLeaveCallback: callback } = renderTooltipResult; setOnMouseLeave((() => callback: () => () => mixed)); updateTooltip.current = renderTooltipResult.updateTooltip; } }, [availablePositions, createTooltip, renderTooltip, tooltipSize], ); React.useEffect(() => { if (!updateTooltip.current) { return; } const tooltipPositionStyle = getTooltipPositionStyle({ tooltipSourcePosition, tooltipSize, availablePositions, }); if (!tooltipPositionStyle) { return; } const tooltip = createTooltip(tooltipPositionStyle); updateTooltip.current?.(tooltip); }, [availablePositions, createTooltip, tooltipSize, tooltipSourcePosition]); return { onMouseEnter, onMouseLeave, }; } function useMessageTooltipSidebarAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { threadCreatedFromMessage, messageInfo } = item; const { popModal } = useModalContext(); const sidebarExists = !!threadCreatedFromMessage; const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( threadInfo, item, ); const openThread = useOnClickThread(threadCreatedFromMessage); const openPendingSidebar = useOnClickPendingSidebar(messageInfo, threadInfo); return React.useMemo(() => { if (!sidebarExistsOrCanBeCreated) { return null; } const buttonContent = ; const onClick = (event: SyntheticEvent) => { popModal(); if (threadCreatedFromMessage) { openThread(event); } else { openPendingSidebar(event); } }; return { actionButtonContent: buttonContent, onClick, label: sidebarExists ? 'Go to thread' : 'Create thread', }; }, [ popModal, openPendingSidebar, openThread, sidebarExists, sidebarExistsOrCanBeCreated, threadCreatedFromMessage, ]); } function useMessageTooltipReplyAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; const { popModal } = useModalContext(); const inputState = React.useContext(InputStateContext); invariant(inputState, 'inputState is required'); const { addReply } = inputState; return React.useMemo(() => { if ( item.messageInfo.type !== messageTypes.TEXT || !threadHasPermission(threadInfo, threadPermissions.VOICED) ) { return null; } const buttonContent = ; const onClick = () => { popModal(); if (!messageInfo.text) { return; } addReply(createMessageReply(messageInfo.text)); }; return { actionButtonContent: buttonContent, onClick, label: 'Reply', }; }, [popModal, addReply, item.messageInfo.type, messageInfo, threadInfo]); } const copiedMessageDurationMs = 2000; function useMessageCopyAction( item: ChatMessageInfoItem, ): ?MessageTooltipAction { const { messageInfo } = item; const [successful, setSuccessful] = useResettingState( false, copiedMessageDurationMs, ); return React.useMemo(() => { if (messageInfo.type !== messageTypes.TEXT) { return null; } const buttonContent = ; const onClick = async () => { try { await navigator.clipboard.writeText(messageInfo.text); setSuccessful(true); } catch (e) { setSuccessful(false); } }; return { actionButtonContent: buttonContent, onClick, label: successful ? 'Copied!' : 'Copy', }; }, [messageInfo.text, messageInfo.type, setSuccessful, successful]); } function useMessageReactAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; const { setShouldRenderEmojiKeyboard } = useTooltipContext(); const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); return React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } const buttonContent = ; const onClickReact = () => { if (!setShouldRenderEmojiKeyboard) { return; } setShouldRenderEmojiKeyboard(true); }; return { actionButtonContent: buttonContent, onClick: onClickReact, label: 'React', }; }, [canCreateReactionFromMessage, setShouldRenderEmojiKeyboard]); } function useMessageTogglePinAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { pushModal } = useModalContext(); const { messageInfo, isPinned } = item; - const canTogglePin = - !isInvalidPinSource(messageInfo) && - threadHasPermission(threadInfo, threadPermissions.MANAGE_PINS) && - threadInfo.sourceMessageID !== item.messageInfo.id; + const canTogglePin = canToggleMessagePin(messageInfo, threadInfo); const inputState = React.useContext(InputStateContext); return React.useMemo(() => { if (!canTogglePin) { return null; } const iconName = isPinned ? 'unpin' : 'pin'; const buttonContent = ; const onClickTogglePin = () => { pushModal( , ); }; return { actionButtonContent: buttonContent, onClick: onClickTogglePin, label: isPinned ? 'Unpin' : 'Pin', }; }, [canTogglePin, inputState, isPinned, pushModal, item, threadInfo]); } function useMessageEditAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; const canEditMessage = useCanEditMessage(threadInfo, messageInfo); const { renderEditModal, scrollToMessage } = useEditModalContext(); const { clearTooltip } = useTooltipContext(); return React.useMemo(() => { if (!canEditMessage) { return null; } const buttonContent = ; const onClickEdit = () => { const callback = (maxHeight: number) => renderEditModal({ messageInfo: item, threadInfo, isError: false, editedMessageDraft: messageInfo.text, maxHeight: maxHeight, }); clearTooltip(); scrollToMessage(getComposedMessageID(messageInfo), callback); }; return { actionButtonContent: buttonContent, onClick: onClickEdit, label: 'Edit', }; }, [ canEditMessage, clearTooltip, item, messageInfo, renderEditModal, scrollToMessage, threadInfo, ]); } function useMessageTooltipActions( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): $ReadOnlyArray { const sidebarAction = useMessageTooltipSidebarAction(item, threadInfo); const replyAction = useMessageTooltipReplyAction(item, threadInfo); const copyAction = useMessageCopyAction(item); const reactAction = useMessageReactAction(item, threadInfo); const togglePinAction = useMessageTogglePinAction(item, threadInfo); const editAction = useMessageEditAction(item, threadInfo); return React.useMemo( () => [ replyAction, sidebarAction, copyAction, reactAction, togglePinAction, editAction, ].filter(Boolean), [ replyAction, sidebarAction, copyAction, reactAction, togglePinAction, editAction, ], ); } const undefinedTooltipSize = { width: 0, height: 0, }; type UseMessageTooltipArgs = { +availablePositions: $ReadOnlyArray, +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, }; function useMessageTooltip({ availablePositions, item, threadInfo, }: UseMessageTooltipArgs): UseTooltipResult { const tooltipActions = useMessageTooltipActions(item, threadInfo); const messageTimestamp = React.useMemo(() => { const time = item.messageInfo.time; return longAbsoluteDate(time); }, [item.messageInfo.time]); const tooltipSize = React.useMemo(() => { if (typeof document === 'undefined') { return undefinedTooltipSize; } const tooltipLabels = tooltipActions.map(action => action.label); return calculateMessageTooltipSize({ tooltipLabels, timestamp: messageTimestamp, }); }, [messageTimestamp, tooltipActions]); const createMessageTooltip = React.useCallback( tooltipPositionStyle => ( ), [item, messageTimestamp, threadInfo, tooltipActions, tooltipSize], ); const { onMouseEnter, onMouseLeave } = useTooltip({ createTooltip: createMessageTooltip, tooltipSize, availablePositions, }); return { onMouseEnter, onMouseLeave, }; } type UseReactionTooltipArgs = { +reaction: string, +reactions: ReactionInfo, +availablePositions: $ReadOnlyArray, }; function useReactionTooltip({ reaction, reactions, availablePositions, }: UseReactionTooltipArgs): UseTooltipResult { const { users } = reactions[reaction]; const tooltipSize = React.useMemo(() => { if (typeof document === 'undefined') { return undefinedTooltipSize; } const usernames = users.map(user => user.username).filter(Boolean); return calculateReactionTooltipSize(usernames); }, [users]); const createReactionTooltip = React.useCallback( () => , [reaction, reactions], ); const { onMouseEnter, onMouseLeave } = useTooltip({ createTooltip: createReactionTooltip, tooltipSize, availablePositions, }); return { onMouseEnter, onMouseLeave, }; } export { useMessageTooltipSidebarAction, useMessageTooltipReplyAction, useMessageReactAction, useMessageTooltipActions, useMessageTooltip, useReactionTooltip, };