diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -46,6 +46,7 @@ RelativeUserInfo, } from '../types/user-types'; import { threeDays } from '../utils/date-utils'; +import type { EntityText } from '../utils/entity-text'; import memoize2 from '../utils/memoize'; import { threadInfoSelector, @@ -275,7 +276,7 @@ +startsConversation: boolean, +startsCluster: boolean, endsCluster: boolean, - +robotext: string, + +robotext: EntityText | string, +threadCreatedFromMessage: ?ThreadInfo, +reactions: $ReadOnlyMap, }; diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -31,6 +31,7 @@ } from '../types/messages/reaction'; import { type ThreadInfo } from '../types/thread-types'; import type { RelativeUserInfo, UserInfos } from '../types/user-types'; +import { type EntityText, entityTextToRawString } from '../utils/entity-text'; import { codeBlockRegex, type ParserRules } from './markdown'; import { messageSpecs } from './messages/message-specs'; import { threadIsGroupChat } from './thread-utils'; @@ -90,7 +91,7 @@ function robotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ?ThreadInfo, -): string { +): string | EntityText { const creator = robotextForUser(messageInfo.creator); const messageSpec = messageSpecs[messageInfo.type]; invariant( @@ -105,7 +106,10 @@ }); } -function robotextToRawString(robotext: string): string { +function robotextToRawString(robotext: string | EntityText): string { + if (typeof robotext !== 'string') { + return entityTextToRawString(robotext); + } return decodeURI(robotext.replace(/<([^<>|]+)\|[^<>|]+>/g, '$1')); } diff --git a/lib/shared/messages/message-spec.js b/lib/shared/messages/message-spec.js --- a/lib/shared/messages/message-spec.js +++ b/lib/shared/messages/message-spec.js @@ -14,6 +14,7 @@ import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo, ThreadType } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; +import type { EntityText } from '../../utils/entity-text'; import { type ParserRules } from '../markdown'; import type { GetMessageTitleViewerContext } from '../message-utils'; @@ -100,7 +101,7 @@ messageInfo: Info, creator: string, params: RobotextParams, - ) => string, + ) => string | EntityText, +shimUnsupportedMessageInfo?: ( rawMessageInfo: RawInfo, platformDetails: ?PlatformDetails, diff --git a/lib/shared/notif-utils.js b/lib/shared/notif-utils.js --- a/lib/shared/notif-utils.js +++ b/lib/shared/notif-utils.js @@ -96,12 +96,24 @@ threadInfo: ThreadInfo, ): string { const robotext = robotextForMessageInfo(messageInfo, threadInfo); - const threadEntityRegex = new RegExp(`<[^<>\\|]+\\|t${threadInfo.id}>`); - const threadMadeExplicit = robotext.replace( - threadEntityRegex, - notifThreadName(threadInfo), - ); - return robotextToRawString(threadMadeExplicit); + const threadName = notifThreadName(threadInfo); + if (typeof robotext === 'string') { + const threadEntityRegex = new RegExp(`<[^<>\\|]+\\|t${threadInfo.id}>`); + const threadMadeExplicit = robotext.replace(threadEntityRegex, threadName); + return robotextToRawString(threadMadeExplicit); + } else { + const threadMadeExplicit = robotext.map(entity => { + if ( + typeof entity !== 'string' && + entity.type === 'thread' && + entity.id === threadInfo.id + ) { + return threadName; + } + return entity; + }); + return robotextToRawString(threadMadeExplicit); + } } function notifCollapseKeyForRawMessageInfo( diff --git a/lib/utils/entity-text.js b/lib/utils/entity-text.js new file mode 100644 --- /dev/null +++ b/lib/utils/entity-text.js @@ -0,0 +1,194 @@ +// @flow + +import invariant from 'invariant'; +import * as React from 'react'; + +import { threadNoun } from '../shared/thread-utils'; +import { stringForUser } from '../shared/user-utils'; +import { threadTypes, type ThreadType } from '../types/thread-types'; + +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 + +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; + +// ET is a JS tag function used in template literals, eg. ET`something` +// It allows you to compose raw text and "entities" together +function ET( + strings: $ReadOnlyArray, + ...entities: $ReadOnlyArray +): EntityText { + 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; +} + +type MakePossessiveInput = { +str: string, +isViewer?: ?boolean }; +function makePossessive(input: MakePossessiveInput) { + if (input.isViewer) { + return 'your'; + } + return `${input.str}’s`; +} + +function getNameForThreadEntity(entity: ThreadEntity): 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 = `this ${threadNoun(threadType)}`; + } + if (entity.possessive) { + name = makePossessive({ str: name }); + } + return name; +} + +function getNameForUserEntity(entity: UserEntity): string { + const str = stringForUser(entity); + const { isViewer, possessive } = entity; + if (!possessive) { + return str; + } + return makePossessive({ str, isViewer }); +} + +function entityTextToRawString(entityText: EntityText): string { + const textParts = entityText.map(entity => { + if (typeof entity === 'string') { + return entity; + } else if (entity.type === 'thread') { + return getNameForThreadEntity(entity); + } else if (entity.type === 'color') { + return entity.hex; + } else if (entity.type === 'user') { + return getNameForUserEntity(entity); + } 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); + 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}`, + ); + } + }); +} + +export { ET, entityTextToRawString, entityTextToReact }; diff --git a/lib/utils/entity-text.test.js b/lib/utils/entity-text.test.js new file mode 100644 --- /dev/null +++ b/lib/utils/entity-text.test.js @@ -0,0 +1,25 @@ +// @flow + +import { ET } from './entity-text'; + +const someUserEntity = { + type: 'user', + id: '123', + username: 'hello', +}; + +const someThreadEntity = { + type: 'thread', + id: '456', + display: 'uiName', + name: undefined, + uiName: 'this is a thread', +}; + +describe('ET tag function', () => { + it('should compose', () => { + const first = ET`${someUserEntity} created ${someThreadEntity}`; + const second = ET`${someUserEntity} sent a message: ${first}`; + expect(second.length).toBe(5); + }); +}); diff --git a/native/chat/chat-item-height-measurer.react.js b/native/chat/chat-item-height-measurer.react.js --- a/native/chat/chat-item-height-measurer.react.js +++ b/native/chat/chat-item-height-measurer.react.js @@ -4,7 +4,7 @@ import * as React from 'react'; import type { ChatMessageItem } from 'lib/selectors/chat-selectors'; -import { messageID } from 'lib/shared/message-utils'; +import { messageID, robotextToRawString } from 'lib/shared/message-utils'; import { messageTypes, type MessageType } from 'lib/types/message-types'; import NodeHeightMeasurer from '../components/node-height-measurer.react'; @@ -28,8 +28,8 @@ const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return messageInfo.text; - } else if (item.robotext && typeof item.robotext === 'string') { - return item.robotext; + } else if (item.robotext) { + return robotextToRawString(item.robotext); } return null; }; @@ -42,7 +42,7 @@ const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return dummyNodeForTextMessageHeightMeasurement(messageInfo.text); - } else if (item.robotext && typeof item.robotext === 'string') { + } else if (item.robotext) { return dummyNodeForRobotextMessageHeightMeasurement(item.robotext); } invariant(false, 'NodeHeightMeasurer asked for dummy for non-text message'); diff --git a/native/chat/inner-robotext-message.react.js b/native/chat/inner-robotext-message.react.js --- a/native/chat/inner-robotext-message.react.js +++ b/native/chat/inner-robotext-message.react.js @@ -10,6 +10,7 @@ parseRobotextEntity, robotextToRawString, } from 'lib/shared/message-utils'; +import { entityTextToReact, type EntityText } from 'lib/utils/entity-text'; import Markdown from '../markdown/markdown.react'; import { inlineMarkdownRules } from '../markdown/rules.react'; @@ -19,7 +20,7 @@ import { useNavigateToThread } from './message-list-types'; function dummyNodeForRobotextMessageHeightMeasurement( - robotext: string, + robotext: string | EntityText, ): React.Element { return ( @@ -43,6 +44,26 @@ const { messageInfo, robotext } = item; const { threadID } = messageInfo; const textParts = React.useMemo(() => { + const darkColor = activeTheme === 'dark'; + + if (typeof robotext !== 'string') { + return entityTextToReact(robotext, 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 }) => , + }); + } + const robotextParts = splitRobotext(robotext); const result = []; let keyIndex = 0; @@ -53,7 +74,6 @@ } const key = `text${keyIndex++}`; if (splitPart.charAt(0) !== '<') { - const darkColor = activeTheme === 'dark'; result.push( , diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js --- a/web/chat/robotext-message.react.js +++ b/web/chat/robotext-message.react.js @@ -8,6 +8,7 @@ import { splitRobotext, parseRobotextEntity } from 'lib/shared/message-utils'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo } from 'lib/types/thread-types'; +import { type EntityText, entityTextToReact } from 'lib/utils/entity-text'; import Markdown from '../markdown/markdown.react'; import { linkRules } from '../markdown/rules.react'; @@ -70,6 +71,15 @@ linkedRobotext() { const { item } = this.props; const { robotext } = item; + const { threadID } = item.messageInfo; + if (typeof robotext === 'string') { + return this.renderStringRobotext(robotext, threadID); + } else { + return this.renderEntityText(robotext, threadID); + } + } + + renderStringRobotext(robotext: string, threadID: string): React.Node { const robotextParts = splitRobotext(robotext); const textParts = []; let keyIndex = 0; @@ -89,7 +99,7 @@ const { rawText, entityType, id } = parseRobotextEntity(splitPart); - if (entityType === 't' && id !== item.messageInfo.threadID) { + if (entityType === 't' && id !== threadID) { textParts.push(); } else if (entityType === 'c') { textParts.push(); @@ -100,6 +110,16 @@ return textParts; } + + renderEntityText(entityText: EntityText, threadID: string): React.Node { + return entityTextToReact(entityText, threadID, { + renderText: ({ text }) => ( + {text} + ), + renderThread: ({ id, name }) => , + renderColor: ({ hex }) => , + }); + } } type BaseInnerThreadEntityProps = { +id: string,