diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index d21dcb9b9..12fc6817a 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,539 +1,551 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy'; import _orderBy from 'lodash/fp/orderBy'; import * as React from 'react'; import { useStringForUser } from '../hooks/ens-cache'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors'; import type { PlatformDetails } from '../types/device-types'; import type { Media } from '../types/media-types'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageTruncationStatus, type MultimediaMessageData, type MessageStore, type ComposableMessageInfo, messageTypes, messageTruncationStatus, type RawComposableMessageInfo, type ThreadMessageInfo, } from '../types/message-types'; import type { ImagesMessageData } from '../types/messages/images'; import type { MediaMessageData } from '../types/messages/media'; import type { RawReactionMessageInfo, ReactionMessageInfo, } from '../types/messages/reaction'; import { type ThreadInfo } from '../types/thread-types'; import type { UserInfos } from '../types/user-types'; -import { type EntityText, entityTextToRawString } from '../utils/entity-text'; +import { type EntityText, useEntityTextAsString } from '../utils/entity-text'; import { codeBlockRegex, type ParserRules } from './markdown'; import { messageSpecs } from './messages/message-specs'; import { threadIsGroupChat } from './thread-utils'; const localIDPrefix = 'local'; // 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, ): EntityText { const messageSpec = messageSpecs[messageInfo.type]; invariant( messageSpec.robotext, `we're not aware of messageType ${messageInfo.type}`, ); return messageSpec.robotext(messageInfo, { threadInfo }); } 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 && platformDetails.platform === 'web') { return [...rawMessageInfos]; } return rawMessageInfos.map(rawMessageInfo => { const { shimUnsupportedMessageInfo } = messageSpecs[rawMessageInfo.type]; if (shimUnsupportedMessageInfo) { return shimUnsupportedMessageInfo(rawMessageInfo, platformDetails); } return rawMessageInfo; }); } type MediaMessageDataCreationInput = $ReadOnly<{ threadID: string, creatorID: string, media: $ReadOnlyArray, localID?: ?string, time?: ?number, ... }>; function createMediaMessageData( input: MediaMessageDataCreationInput, ): MultimediaMessageData { let allMediaArePhotos = true; const photoMedia = []; for (const singleMedia of input.media) { if (singleMedia.type === 'video') { allMediaArePhotos = false; break; } else { photoMedia.push(singleMedia); } } const { localID, threadID, creatorID } = input; const time = input.time ? input.time : Date.now(); let messageData; if (allMediaArePhotos) { messageData = ({ type: messageTypes.IMAGES, threadID, creatorID, time, media: photoMedia, }: ImagesMessageData); } else { messageData = ({ type: messageTypes.MULTIMEDIA, threadID, creatorID, time, media: input.media, }: MediaMessageData); } if (localID) { messageData.localID = localID; } return messageData; } type MediaMessageInfoCreationInput = $ReadOnly<{ ...$Exact, id?: ?string, }>; function createMediaMessageInfo( input: MediaMessageInfoCreationInput, ): RawMultimediaMessageInfo { const messageData = createMediaMessageData(input); 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, ) { 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 getMessageTitle( messageInfo: | ComposableMessageInfo | RobotextMessageInfo | ReactionMessageInfo, threadInfo: 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, 'messageTitle can only be auto-generated for RobotextMessageInfo', ); return robotextForMessageInfo(messageInfo, threadInfo); } 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, lastNavigatedTo: Math.max(first.lastNavigatedTo, second.lastNavigatedTo), lastPruned: Math.max(first.lastPruned, second.lastPruned), }; } type MessagePreviewPart = { +text: string, // unread has highest contrast, followed by primary, followed by secondary +style: 'unread' | 'primary' | 'secondary', }; 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 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 message = React.useMemo(() => { + const messageTitleEntityText = React.useMemo(() => { if (!messageInfo) { return messageInfo; } - const messageTitleEntityText = getMessageTitle( - messageInfo, - threadInfo, - markdownRules, - ); - const messageTitle = entityTextToRawString(messageTitleEntityText, { - threadID: threadInfo.id, - }); + return getMessageTitle(messageInfo, threadInfo, markdownRules); + }, [messageInfo, threadInfo, 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 (messageInfo.type === messageTypes.TEXT) { + } else if (isTextMessage) { style = 'primary'; } else { style = 'secondary'; } return { text: messageTitle, style }; - }, [messageInfo, threadInfo, markdownRules, unread]); + }, [messageTitle, unread, isTextMessage]); return React.useMemo(() => { if (!message) { return message; } return { message, username }; }, [message, username]); } export { localIDPrefix, messageKey, messageID, robotextForMessageInfo, createMessageInfo, sortMessageInfoList, sortMessageIDs, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, usersInMessageInfos, combineTruncationStatuses, shimUnsupportedRawMessageInfos, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageQuote, createMessageReply, getMostRecentNonLocalMessageID, getMessageTitle, mergeThreadMessageInfos, useMessagePreview, }; diff --git a/lib/utils/entity-text.js b/lib/utils/entity-text.js index 110bffa5d..dfcb4eafe 100644 --- a/lib/utils/entity-text.js +++ b/lib/utils/entity-text.js @@ -1,362 +1,378 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useENSNames } from '../hooks/ens-cache'; import { threadNoun } from '../shared/thread-utils'; import { stringForUser } from '../shared/user-utils'; import { threadTypes, type ThreadType, type RawThreadInfo, type ThreadInfo, } from '../types/thread-types'; import { basePluralize } from '../utils/text-utils'; type UserEntity = { +type: 'user', +id: string, +username?: ?string, +isViewer?: ?boolean, +possessive?: ?boolean, // eg. `user's` instead of `user` }; // Comments explain how thread name will appear from user4's perspective type ThreadEntity = | { +type: 'thread', +id: string, +name?: ?string, // displays threadInfo.name if set, or 'user1, user2, and user3' +display: 'uiName', +uiName: string, } | { +type: 'thread', +id: string, +name?: ?string, // displays threadInfo.name if set, or eg. 'this thread' or 'this chat' +display: 'shortName', +threadType?: ?ThreadType, +alwaysDisplayShortName?: ?boolean, // don't default to name +subchannel?: ?boolean, // short name should be "subchannel" +possessive?: ?boolean, // eg. `this thread's` instead of `this thread` }; type ColorEntity = { +type: 'color', +hex: string, }; type EntityTextComponent = UserEntity | ThreadEntity | ColorEntity | string; export type EntityText = $ReadOnlyArray; const entityTextFunction = ( strings: $ReadOnlyArray, ...entities: $ReadOnlyArray ) => { const result = []; for (let i = 0; i < strings.length; i++) { const str = strings[i]; if (str) { result.push(str); } const entity = entities[i]; if (!entity) { continue; } if (typeof entity === 'string') { const lastResult = result.length > 0 && result[result.length - 1]; if (typeof lastResult === 'string') { result[result.length - 1] = lastResult + entity; } else { result.push(entity); } } else if (Array.isArray(entity)) { const [firstEntity, ...restOfEntity] = entity; const lastResult = result.length > 0 && result[result.length - 1]; if (typeof lastResult === 'string' && typeof firstEntity === 'string') { result[result.length - 1] = lastResult + firstEntity; } else if (firstEntity) { result.push(firstEntity); } result.push(...restOfEntity); } else { result.push(entity); } } return result; }; // defaults to shortName type EntityTextThreadInput = | { +display: 'uiName', +threadInfo: ThreadInfo, } | { +display?: 'shortName', +threadInfo: ThreadInfo | RawThreadInfo, +subchannel?: ?boolean, +possessive?: ?boolean, } | { +display: 'alwaysDisplayShortName', +threadInfo: ThreadInfo | RawThreadInfo, +possessive?: ?boolean, } | { +display: 'alwaysDisplayShortName', +threadID: string, +possessive?: ?boolean, }; entityTextFunction.thread = (input: EntityTextThreadInput) => { if (input.display === 'uiName') { const { threadInfo } = input; return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: threadInfo.uiName, }; } if (input.display === 'alwaysDisplayShortName' && input.threadID) { const { threadID, possessive } = input; return { type: 'thread', id: threadID, name: undefined, display: 'shortName', threadType: undefined, alwaysDisplayShortName: true, possessive, }; } else if (input.display === 'alwaysDisplayShortName' && input.threadInfo) { const { threadInfo, possessive } = input; return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'shortName', threadType: threadInfo.type, alwaysDisplayShortName: true, possessive, }; } else if (input.display === 'shortName' || !input.display) { const { threadInfo, subchannel, possessive } = input; return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'shortName', threadType: threadInfo.type, subchannel, possessive, }; } invariant( false, `ET.thread passed unexpected display type: ${input.display}`, ); }; type EntityTextUserInput = { +userInfo: { +id: string, +username?: ?string, +isViewer?: ?boolean, ... }, +possessive?: ?boolean, }; entityTextFunction.user = (input: EntityTextUserInput) => ({ type: 'user', id: input.userInfo.id, username: input.userInfo.username, isViewer: input.userInfo.isViewer, possessive: input.possessive, }); type EntityTextColorInput = { +hex: string }; entityTextFunction.color = (input: EntityTextColorInput) => ({ type: 'color', hex: input.hex, }); // ET is a JS tag function used in template literals, eg. ET`something` // It allows you to compose raw text and "entities" together type EntityTextFunction = (( strings: $ReadOnlyArray, ...entities: $ReadOnlyArray ) => EntityText) & { +thread: EntityTextThreadInput => ThreadEntity, +user: EntityTextUserInput => UserEntity, +color: EntityTextColorInput => ColorEntity, ... }; const ET: EntityTextFunction = entityTextFunction; type MakePossessiveInput = { +str: string, +isViewer?: ?boolean }; function makePossessive(input: MakePossessiveInput) { if (input.isViewer) { return 'your'; } return `${input.str}’s`; } function getNameForThreadEntity( entity: ThreadEntity, threadID: ?string, ): string { const { name: userGeneratedName, display } = entity; if (entity.display === 'uiName') { return userGeneratedName ? userGeneratedName : entity.uiName; } invariant( entity.display === 'shortName', `getNameForThreadEntity can't handle thread entity display ${display}`, ); let name = userGeneratedName; if (!name || entity.alwaysDisplayShortName) { const threadType = entity.threadType ?? threadTypes.PERSONAL; name = entity.subchannel ? 'subchannel' : threadNoun(threadType); if (entity.id === threadID) { name = `this ${name}`; } } if (entity.possessive) { name = makePossessive({ str: name }); } return name; } function getNameForUserEntity( entity: UserEntity, ignoreViewer: ?boolean, ): string { const isViewer = entity.isViewer && !ignoreViewer; const entityWithIsViewerIgnored = { ...entity, isViewer }; const str = stringForUser(entityWithIsViewerIgnored); if (!entityWithIsViewerIgnored.possessive) { return str; } return makePossessive({ str, isViewer }); } type EntityTextToRawStringParams = { +threadID?: ?string, +ignoreViewer?: ?boolean, }; function entityTextToRawString( entityText: EntityText, params?: EntityTextToRawStringParams, ): string { const textParts = entityText.map(entity => { if (typeof entity === 'string') { return entity; } else if (entity.type === 'thread') { return getNameForThreadEntity(entity, params?.threadID); } else if (entity.type === 'color') { return entity.hex; } else if (entity.type === 'user') { return getNameForUserEntity(entity, params?.ignoreViewer); } else { invariant( false, `entityTextToRawString can't handle entity type ${entity.type}`, ); } }); return textParts.join(''); } type RenderFunctions = { +renderText: ({ +text: string }) => React.Node, +renderThread: ({ +id: string, +name: string }) => React.Node, +renderColor: ({ +hex: string }) => React.Node, }; function entityTextToReact( entityText: EntityText, threadID: string, renderFuncs: RenderFunctions, ): React.Node { const { renderText, renderThread, renderColor } = renderFuncs; return entityText.map((entity, i) => { const key = `text${i}`; if (typeof entity === 'string') { return ( {renderText({ text: entity })} ); } else if (entity.type === 'thread') { const { id } = entity; const name = getNameForThreadEntity(entity, threadID); if (id === threadID) { return name; } else { return ( {renderThread({ id, name })} ); } } else if (entity.type === 'color') { return ( {renderColor({ hex: entity.hex })} ); } else if (entity.type === 'user') { return getNameForUserEntity(entity); } else { invariant( false, `entityTextToReact can't handle entity type ${entity.type}`, ); } }); } function pluralizeEntityText( nouns: $ReadOnlyArray, maxNumberOfNouns: number = 3, ): EntityText { return basePluralize( nouns, maxNumberOfNouns, (a: EntityText | string, b: ?EntityText | string) => b ? ET`${a}${b}` : ET`${a}`, ); } -function useENSNamesForEntityText(entityText: EntityText): EntityText { - const allObjects = React.useMemo( - () => - entityText.map(entity => - typeof entity === 'string' ? { type: 'text', text: entity } : entity, - ), - [entityText], - ); +function useENSNamesForEntityText(entityText: ?EntityText): ?EntityText { + const allObjects = React.useMemo(() => { + if (!entityText) { + return []; + } + return entityText.map(entity => + typeof entity === 'string' ? { type: 'text', text: entity } : entity, + ); + }, [entityText]); const objectsWithENSNames = useENSNames(allObjects); - return React.useMemo( - () => - objectsWithENSNames.map(entity => - entity.type === 'text' ? entity.text : entity, - ), - [objectsWithENSNames], - ); + return React.useMemo(() => { + if (!entityText) { + return entityText; + } + return objectsWithENSNames.map(entity => + entity.type === 'text' ? entity.text : entity, + ); + }, [entityText, objectsWithENSNames]); +} + +function useEntityTextAsString( + entityText: ?EntityText, + params?: EntityTextToRawStringParams, +): ?string { + const withENSNames = useENSNamesForEntityText(entityText); + return React.useMemo(() => { + if (!withENSNames) { + return withENSNames; + } + return entityTextToRawString(withENSNames, params); + }, [withENSNames, params]); } export { ET, entityTextToRawString, entityTextToReact, pluralizeEntityText, useENSNamesForEntityText, + useEntityTextAsString, }; diff --git a/native/chat/inner-robotext-message.react.js b/native/chat/inner-robotext-message.react.js index e4553aaf0..1efba1f44 100644 --- a/native/chat/inner-robotext-message.react.js +++ b/native/chat/inner-robotext-message.react.js @@ -1,143 +1,147 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, TouchableWithoutFeedback, View } from 'react-native'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { entityTextToReact, entityTextToRawString, useENSNamesForEntityText, type EntityText, } from 'lib/utils/entity-text'; import Markdown from '../markdown/markdown.react'; import { inlineMarkdownRules } from '../markdown/rules.react'; import { useSelector } from '../redux/redux-utils'; import { useOverlayStyles } from '../themes/colors'; import type { ChatRobotextMessageInfoItemWithHeight } from '../types/chat-types'; import { useNavigateToThread } from './message-list-types'; function dummyNodeForRobotextMessageHeightMeasurement( robotext: EntityText, threadID: string, ): React.Element { return ( {entityTextToRawString(robotext, { threadID })} ); } type InnerRobotextMessageProps = { +item: ChatRobotextMessageInfoItemWithHeight, +onPress: () => void, +onLongPress?: () => void, }; function InnerRobotextMessage(props: InnerRobotextMessageProps): React.Node { const { item, onLongPress, onPress } = props; const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const styles = useOverlayStyles(unboundStyles); const { messageInfo, robotext } = item; const { threadID } = messageInfo; const robotextWithENSNames = useENSNamesForEntityText(robotext); + invariant( + robotextWithENSNames, + 'useENSNamesForEntityText only returns falsey when passed falsey', + ); const textParts = React.useMemo(() => { const darkColor = activeTheme === 'dark'; return entityTextToReact(robotextWithENSNames, threadID, { // eslint-disable-next-line react/display-name renderText: ({ text }) => ( {text} ), // eslint-disable-next-line react/display-name renderThread: ({ id, name }) => , // eslint-disable-next-line react/display-name renderColor: ({ hex }) => , }); }, [robotextWithENSNames, activeTheme, threadID, styles.robotext]); const viewStyle = [styles.robotextContainer]; if (!__DEV__) { // We don't force view height in dev mode because we // want to measure it in Message to see if it's correct viewStyle.push({ height: item.contentHeight }); } return ( {textParts} ); } type ThreadEntityProps = { +id: string, +name: string, }; function ThreadEntity(props: ThreadEntityProps) { const threadID = props.id; const threadInfo = useSelector(state => threadInfoSelector(state)[threadID]); const styles = useOverlayStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const onPressThread = React.useCallback(() => { invariant(threadInfo, 'onPressThread should have threadInfo'); navigateToThread({ threadInfo }); }, [threadInfo, navigateToThread]); if (!threadInfo) { return {props.name}; } return ( {props.name} ); } function ColorEntity(props: { +color: string }) { const colorStyle = { color: props.color }; return {props.color}; } const unboundStyles = { link: { color: 'link', }, robotextContainer: { paddingTop: 6, paddingBottom: 11, paddingHorizontal: 24, }, robotext: { color: 'listForegroundSecondaryLabel', fontFamily: 'Arial', fontSize: 15, textAlign: 'center', }, dummyRobotext: { fontFamily: 'Arial', fontSize: 15, textAlign: 'center', }, }; const MemoizedInnerRobotextMessage: React.ComponentType = React.memo( InnerRobotextMessage, ); export { dummyNodeForRobotextMessageHeightMeasurement, MemoizedInnerRobotextMessage as InnerRobotextMessage, }; diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js index 0ed91d016..8ac660fdf 100644 --- a/web/chat/robotext-message.react.js +++ b/web/chat/robotext-message.react.js @@ -1,139 +1,144 @@ // @flow +import invariant from 'invariant'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { type RobotextChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { entityTextToReact, useENSNamesForEntityText, } from 'lib/utils/entity-text'; import Markdown from '../markdown/markdown.react'; import { linkRules } from '../markdown/rules.react'; import { updateNavInfoActionType } from '../redux/action-types'; import { useSelector } from '../redux/redux-utils'; import { tooltipPositions, useMessageTooltip } from '../utils/tooltip-utils'; import InlineEngagement from './inline-engagement.react'; import css from './robotext-message.css'; const availableTooltipPositionsForRobotext = [ tooltipPositions.LEFT, tooltipPositions.LEFT_TOP, tooltipPositions.LEFT_BOTTOM, tooltipPositions.RIGHT, tooltipPositions.RIGHT_TOP, tooltipPositions.RIGHT_BOTTOM, ]; type Props = { +item: RobotextChatMessageInfoItem, +threadInfo: ThreadInfo, }; function RobotextMessage(props: Props): React.Node { let inlineEngagement; const { item } = props; const { threadCreatedFromMessage, reactions } = item; if (threadCreatedFromMessage || reactions.size > 0) { inlineEngagement = (
); } const { messageInfo, robotext } = item; const { threadID } = messageInfo; const robotextWithENSNames = useENSNamesForEntityText(robotext); + invariant( + robotextWithENSNames, + 'useENSNamesForEntityText only returns falsey when passed falsey', + ); const textParts = React.useMemo(() => { return entityTextToReact(robotextWithENSNames, threadID, { // eslint-disable-next-line react/display-name renderText: ({ text }) => ( {text} ), // eslint-disable-next-line react/display-name renderThread: ({ id, name }) => , // eslint-disable-next-line react/display-name renderColor: ({ hex }) => , }); }, [robotextWithENSNames, threadID]); const { threadInfo } = props; const { onMouseEnter, onMouseLeave } = useMessageTooltip({ item, threadInfo, availablePositions: availableTooltipPositionsForRobotext, }); return (
{textParts}
{inlineEngagement}
); } type BaseInnerThreadEntityProps = { +id: string, +name: string, }; type InnerThreadEntityProps = { ...BaseInnerThreadEntityProps, +threadInfo: ThreadInfo, +dispatch: Dispatch, }; class InnerThreadEntity extends React.PureComponent { render() { return {this.props.name}; } onClickThread = (event: SyntheticEvent) => { event.preventDefault(); const id = this.props.id; this.props.dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: id, }, }); }; } const ThreadEntity = React.memo( function ConnectedInnerThreadEntity(props: BaseInnerThreadEntityProps) { const { id } = props; const threadInfo = useSelector(state => threadInfoSelector(state)[id]); const dispatch = useDispatch(); return ( ); }, ); function ColorEntity(props: { color: string }) { const colorStyle = { color: props.color }; return {props.color}; } const MemoizedRobotextMessage: React.ComponentType = React.memo( RobotextMessage, ); export default MemoizedRobotextMessage;