diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index 956bca5df..ead6949e2 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,1077 +1,1077 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy'; import { shimUploadURI, multimediaMessagePreview } from '../media/media-utils'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors'; import type { PlatformDetails } from '../types/device-types'; import type { Media, Image, Video } from '../types/media-types'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type PreviewableMessageInfo, type TextMessageInfo, type MediaMessageInfo, type ImagesMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageType, type MessageTruncationStatus, type RawImagesMessageInfo, type RawMediaMessageInfo, type MultimediaMessageData, type MediaMessageData, type ImagesMessageData, type MessageStore, messageTypes, messageTruncationStatus, } from '../types/message-types'; import { type ThreadInfo, threadTypes } from '../types/thread-types'; import type { RelativeUserInfo, UserInfos } from '../types/user-types'; import { prettyDate } from '../utils/date-utils'; import { codeBlockRegex } from './markdown'; import { stringForUser } from './user-utils'; import { hasMinCodeVersion } from './version-utils'; // 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 robotextForUser(user: RelativeUserInfo): string { if (user.isViewer) { return 'you'; } else if (user.username) { return `<${encodeURI(user.username)}|u${user.id}>`; } else { return 'anonymous'; } } function robotextForUsers(users: RelativeUserInfo[]): string { if (users.length === 1) { return robotextForUser(users[0]); } else if (users.length === 2) { return `${robotextForUser(users[0])} and ${robotextForUser(users[1])}`; } else if (users.length === 3) { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${robotextForUser(users[2])}` ); } else { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${users.length - 2} others` ); } } function encodedThreadEntity(threadID: string, text: string): string { return `<${text}|t${threadID}>`; } function newThreadRobotext(messageInfo: RobotextMessageInfo, creator: string) { invariant( messageInfo.type === messageTypes.CREATE_THREAD || messageInfo.type === messageTypes.CREATE_SIDEBAR, `Expected CREATE_THREAD or CREATE_SIDEBAR message type, but received ${messageInfo.type}`, ); const threadTypeText = messageInfo.type === messageTypes.CREATE_SIDEBAR ? 'sidebar' : 'thread'; let text = `created ${encodedThreadEntity( messageInfo.threadID, `this ${threadTypeText}`, )}`; const parentThread = messageInfo.initialThreadState.parentThreadInfo; if (parentThread) { text += ' as a child of ' + `<${encodeURI(parentThread.uiName)}|t${parentThread.id}>`; } if (messageInfo.initialThreadState.name) { text += ` with the name "${encodeURI( messageInfo.initialThreadState.name, )}"`; } const users = messageInfo.initialThreadState.otherMembers; if (users.length !== 0) { const initialUsersString = robotextForUsers(users); text += ` and added ${initialUsersString}`; } return `${creator} ${text}`; } function robotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, ): string { const creator = robotextForUser(messageInfo.creator); if (messageInfo.type === messageTypes.CREATE_THREAD) { return newThreadRobotext(messageInfo, creator); } else if (messageInfo.type === messageTypes.ADD_MEMBERS) { const users = messageInfo.addedMembers; invariant(users.length !== 0, 'added who??'); const addedUsersString = robotextForUsers(users); return `${creator} added ${addedUsersString}`; } else if (messageInfo.type === messageTypes.CREATE_SUB_THREAD) { const childName = messageInfo.childThreadInfo.name; const childNoun = messageInfo.childThreadInfo.type === threadTypes.SIDEBAR ? 'sidebar' : 'child thread'; if (childName) { return ( `${creator} created a ${childNoun}` + ` named "<${encodeURI(childName)}|t${messageInfo.childThreadInfo.id}>"` ); } else { return ( `${creator} created a ` + `<${childNoun}|t${messageInfo.childThreadInfo.id}>` ); } } else if (messageInfo.type === messageTypes.CHANGE_SETTINGS) { let value; if (messageInfo.field === 'color') { value = `<#${messageInfo.value}|c${messageInfo.threadID}>`; } else { value = messageInfo.value; } return ( `${creator} updated ` + `${encodedThreadEntity(messageInfo.threadID, 'the thread')}'s ` + `${messageInfo.field} to "${value}"` ); } else if (messageInfo.type === messageTypes.REMOVE_MEMBERS) { const users = messageInfo.removedMembers; invariant(users.length !== 0, 'removed who??'); const removedUsersString = robotextForUsers(users); return `${creator} removed ${removedUsersString}`; } else if (messageInfo.type === messageTypes.CHANGE_ROLE) { const users = messageInfo.members; invariant(users.length !== 0, 'changed whose role??'); const usersString = robotextForUsers(users); const verb = threadInfo.roles[messageInfo.newRole].isDefault ? 'removed' : 'added'; const noun = users.length === 1 ? 'an admin' : 'admins'; return `${creator} ${verb} ${usersString} as ${noun}`; } else if (messageInfo.type === messageTypes.LEAVE_THREAD) { return ( `${creator} left ` + encodedThreadEntity(messageInfo.threadID, 'this thread') ); } else if (messageInfo.type === messageTypes.JOIN_THREAD) { return ( `${creator} joined ` + encodedThreadEntity(messageInfo.threadID, 'this thread') ); } else if (messageInfo.type === messageTypes.CREATE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} created an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.EDIT_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} updated the text of an event scheduled for ` + `${date}: "${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.DELETE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} deleted an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.RESTORE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} restored an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { const target = robotextForUser(messageInfo.target); if (messageInfo.operation === 'request_sent') { return `${creator} sent ${target} a friend request`; } else if (messageInfo.operation === 'request_accepted') { const targetPossessive = messageInfo.target.isViewer ? 'your' : `${target}'s`; return `${creator} accepted ${targetPossessive} friend request`; } invariant( false, `Invalid operation ${messageInfo.operation} ` + `of message with type ${messageInfo.type}`, ); } else if (messageInfo.type === messageTypes.CREATE_SIDEBAR) { return newThreadRobotext(messageInfo, creator); } else if (messageInfo.type === messageTypes.UNSUPPORTED) { return `${creator} ${messageInfo.robotext}`; } invariant(false, `we're not aware of messageType ${messageInfo.type}`); } function robotextToRawString(robotext: string): string { return decodeURI(robotext.replace(/<([^<>|]+)\|[^<>|]+>/g, '$1')); } function createMessageInfo( rawMessageInfo: RawMessageInfo, viewerID: ?string, userInfos: UserInfos, threadInfos: { [id: string]: ThreadInfo }, ): ?MessageInfo { const creatorInfo = userInfos[rawMessageInfo.creatorID]; if (!creatorInfo) { return null; } if (rawMessageInfo.type === messageTypes.TEXT) { const messageInfo: TextMessageInfo = { type: messageTypes.TEXT, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, text: rawMessageInfo.text, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.CREATE_THREAD) { const initialParentThreadID = rawMessageInfo.initialThreadState.parentThreadID; let parentThreadInfo = null; if (initialParentThreadID) { parentThreadInfo = threadInfos[initialParentThreadID]; } return { type: messageTypes.CREATE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, type: rawMessageInfo.initialThreadState.type, color: rawMessageInfo.initialThreadState.color, otherMembers: userIDsToRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), viewerID, userInfos, ), }, }; } else if (rawMessageInfo.type === messageTypes.ADD_MEMBERS) { const addedMembers = userIDsToRelativeUserInfos( rawMessageInfo.addedUserIDs, viewerID, userInfos, ); return { type: messageTypes.ADD_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, addedMembers, }; } else if (rawMessageInfo.type === messageTypes.CREATE_SUB_THREAD) { const childThreadInfo = threadInfos[rawMessageInfo.childThreadID]; if (!childThreadInfo) { return null; } return { type: messageTypes.CREATE_SUB_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, childThreadInfo, }; } else if (rawMessageInfo.type === messageTypes.CHANGE_SETTINGS) { return { type: messageTypes.CHANGE_SETTINGS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, field: rawMessageInfo.field, value: rawMessageInfo.value, }; } else if (rawMessageInfo.type === messageTypes.REMOVE_MEMBERS) { const removedMembers = userIDsToRelativeUserInfos( rawMessageInfo.removedUserIDs, viewerID, userInfos, ); return { type: messageTypes.REMOVE_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, removedMembers, }; } else if (rawMessageInfo.type === messageTypes.CHANGE_ROLE) { const members = userIDsToRelativeUserInfos( rawMessageInfo.userIDs, viewerID, userInfos, ); return { type: messageTypes.CHANGE_ROLE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, members, newRole: rawMessageInfo.newRole, }; } else if (rawMessageInfo.type === messageTypes.LEAVE_THREAD) { return { type: messageTypes.LEAVE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, }; } else if (rawMessageInfo.type === messageTypes.JOIN_THREAD) { return { type: messageTypes.JOIN_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, }; } else if (rawMessageInfo.type === messageTypes.CREATE_ENTRY) { return { type: messageTypes.CREATE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.EDIT_ENTRY) { return { type: messageTypes.EDIT_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.DELETE_ENTRY) { return { type: messageTypes.DELETE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.RESTORE_ENTRY) { return { type: messageTypes.RESTORE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.UNSUPPORTED) { return { type: messageTypes.UNSUPPORTED, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, robotext: rawMessageInfo.robotext, unsupportedMessageInfo: rawMessageInfo.unsupportedMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.IMAGES) { const messageInfo: ImagesMessageInfo = { type: messageTypes.IMAGES, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const messageInfo: MediaMessageInfo = { type: messageTypes.MULTIMEDIA, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { const target = userInfos[rawMessageInfo.targetID]; if (!target) { return null; } return { type: messageTypes.UPDATE_RELATIONSHIP, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, target: { id: target.id, username: target.username, isViewer: target.id === viewerID, }, time: rawMessageInfo.time, operation: rawMessageInfo.operation, }; } else if (rawMessageInfo.type === messageTypes.SIDEBAR_SOURCE) { const initialMessage = createMessageInfo( rawMessageInfo.initialMessage, viewerID, userInfos, threadInfos, ); invariant( - initialMessage && initialMessage.type === messageTypes.TEXT, - 'Sidebars can be created only from text messages', + initialMessage && initialMessage.type !== messageTypes.SIDEBAR_SOURCE, + 'Sidebars can not be created from SIDEBAR SOURCE', ); return { type: messageTypes.SIDEBAR_SOURCE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, initialMessage, }; } else if (rawMessageInfo.type === messageTypes.CREATE_SIDEBAR) { const parentThreadInfo = threadInfos[rawMessageInfo.initialThreadState.parentThreadID]; return { type: messageTypes.CREATE_SIDEBAR, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, color: rawMessageInfo.initialThreadState.color, otherMembers: userIDsToRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), viewerID, userInfos, ), }, }; } invariant(false, `we're not aware of messageType ${rawMessageInfo.type}`); } function sortMessageInfoList( messageInfos: T[], ): T[] { return messageInfos.sort((a: T, b: T) => b.time - a.time); } function rawMessageInfoFromMessageData( messageData: MessageData, id: string, ): RawMessageInfo { if (messageData.type === messageTypes.TEXT) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CREATE_THREAD) { return { ...messageData, id }; } else if (messageData.type === messageTypes.ADD_MEMBERS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CREATE_SUB_THREAD) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CHANGE_SETTINGS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.REMOVE_MEMBERS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CHANGE_ROLE) { return { ...messageData, id }; } else if (messageData.type === messageTypes.LEAVE_THREAD) { return { type: messageTypes.LEAVE_THREAD, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, }; } else if (messageData.type === messageTypes.JOIN_THREAD) { return { type: messageTypes.JOIN_THREAD, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, }; } else if (messageData.type === messageTypes.CREATE_ENTRY) { return { type: messageTypes.CREATE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.EDIT_ENTRY) { return { type: messageTypes.EDIT_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.DELETE_ENTRY) { return { type: messageTypes.DELETE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.RESTORE_ENTRY) { return { type: messageTypes.RESTORE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.IMAGES) { return ({ ...messageData, id }: RawImagesMessageInfo); } else if (messageData.type === messageTypes.MULTIMEDIA) { return ({ ...messageData, id }: RawMediaMessageInfo); } else if (messageData.type === messageTypes.UPDATE_RELATIONSHIP) { return { ...messageData, id }; } else if (messageData.type === messageTypes.SIDEBAR_SOURCE) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CREATE_SIDEBAR) { return { ...messageData, id }; } else { invariant(false, `we're not aware of messageType ${messageData.type}`); } } function mostRecentMessageTimestamp( messageInfos: RawMessageInfo[], previousTimestamp: number, ): number { if (messageInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(messageInfos).time; } function messageTypeGeneratesNotifs(type: MessageType) { return ( type !== messageTypes.JOIN_THREAD && type !== messageTypes.LEAVE_THREAD && type !== messageTypes.ADD_MEMBERS && type !== messageTypes.REMOVE_MEMBERS && type !== messageTypes.SIDEBAR_SOURCE ); } function splitRobotext(robotext: string) { return robotext.split(/(<[^<>|]+\|[^<>|]+>)/g); } const robotextEntityRegex = /<([^<>|]+)\|([^<>|]+)>/; function parseRobotextEntity(robotextPart: string) { const entityParts = robotextPart.match(robotextEntityRegex); invariant(entityParts && entityParts[1], 'malformed robotext'); const rawText = decodeURI(entityParts[1]); const entityType = entityParts[2].charAt(0); const id = entityParts[2].substr(1); return { rawText, entityType, id }; } function usersInMessageInfos( messageInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (let 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) => { if (rawMessageInfo.type === messageTypes.IMAGES) { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); if (hasMinCodeVersion(platformDetails, 30)) { return shimmedRawMessageInfo; } const { id } = shimmedRawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: shimmedRawMessageInfo.threadID, creatorID: shimmedRawMessageInfo.creatorID, time: shimmedRawMessageInfo.time, robotext: multimediaMessagePreview(shimmedRawMessageInfo), unsupportedMessageInfo: shimmedRawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); // TODO figure out first native codeVersion supporting video playback if (hasMinCodeVersion(platformDetails, 62)) { return shimmedRawMessageInfo; } const { id } = shimmedRawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: shimmedRawMessageInfo.threadID, creatorID: shimmedRawMessageInfo.creatorID, time: shimmedRawMessageInfo.time, robotext: multimediaMessagePreview(shimmedRawMessageInfo), unsupportedMessageInfo: shimmedRawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { if (hasMinCodeVersion(platformDetails, 71)) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'performed a relationship action', unsupportedMessageInfo: rawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.SIDEBAR_SOURCE) { // TODO determine min code version if (hasMinCodeVersion(platformDetails, 75)) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'first message in sidebar', unsupportedMessageInfo: rawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.CREATE_SIDEBAR) { // TODO determine min code version if (hasMinCodeVersion(platformDetails, 75)) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'created a sidebar', unsupportedMessageInfo: rawMessageInfo, }; } return rawMessageInfo; }); } function shimMediaMessageInfo( rawMessageInfo: RawMultimediaMessageInfo, platformDetails: ?PlatformDetails, ): RawMultimediaMessageInfo { if (rawMessageInfo.type === messageTypes.IMAGES) { let uriChanged = false; const newMedia: Image[] = []; for (let singleMedia of rawMessageInfo.media) { const shimmedURI = shimUploadURI(singleMedia.uri, platformDetails); if (shimmedURI === singleMedia.uri) { newMedia.push(singleMedia); } else { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Image)); uriChanged = true; } } if (!uriChanged) { return rawMessageInfo; } return ({ ...rawMessageInfo, media: newMedia, }: RawImagesMessageInfo); } else { let uriChanged = false; const newMedia: Media[] = []; for (let singleMedia of rawMessageInfo.media) { const shimmedURI = shimUploadURI(singleMedia.uri, platformDetails); if (shimmedURI === singleMedia.uri) { newMedia.push(singleMedia); } else if (singleMedia.type === 'photo') { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Image)); uriChanged = true; } else { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Video)); uriChanged = true; } } if (!uriChanged) { return rawMessageInfo; } return ({ ...rawMessageInfo, media: newMedia, }: RawMediaMessageInfo); } } function messagePreviewText( messageInfo: PreviewableMessageInfo, threadInfo: ThreadInfo, ): string { if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const creator = stringForUser(messageInfo.creator); const preview = multimediaMessagePreview(messageInfo); return `${creator} ${preview}`; } return robotextToRawString(robotextForMessageInfo(messageInfo, threadInfo)); } type MediaMessageDataCreationInput = $ReadOnly<{ threadID: string, creatorID: string, media: $ReadOnlyArray, localID?: ?string, time?: ?number, ... }>; function createMediaMessageData( input: MediaMessageDataCreationInput, ): MultimediaMessageData { let allMediaArePhotos = true; const photoMedia = []; for (let 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); // This conditional is for Flow let rawMessageInfo; if (messageData.type === messageTypes.IMAGES) { rawMessageInfo = ({ ...messageData, type: messageTypes.IMAGES, }: RawImagesMessageInfo); } else { rawMessageInfo = ({ ...messageData, type: messageTypes.MULTIMEDIA, }: RawMediaMessageInfo); } if (input.id) { rawMessageInfo.id = input.id; } return rawMessageInfo; } function stripLocalIDs( input: $ReadOnlyArray, ): RawMessageInfo[] { const output = []; for (let rawMessageInfo of input) { if ( rawMessageInfo.localID === null || rawMessageInfo.localID === undefined ) { output.push(rawMessageInfo); continue; } invariant( rawMessageInfo.id, 'serverID should be set if localID is being stripped', ); if (rawMessageInfo.type === messageTypes.TEXT) { const { localID, ...rest } = rawMessageInfo; output.push({ ...rest }); } else if (rawMessageInfo.type === messageTypes.IMAGES) { const { localID, ...rest } = rawMessageInfo; output.push(({ ...rest }: RawImagesMessageInfo)); } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const { localID, ...rest } = rawMessageInfo; output.push(({ ...rest }: RawMediaMessageInfo)); } else { invariant( false, `message ${rawMessageInfo.id} of type ${rawMessageInfo.type} ` + `unexpectedly has localID`, ); } } 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) { message = message.replace(/^\n*/, ''); return codeBlockRegex.exec(message) ? message.trimEnd() : message.trim(); } function createMessageReply(message: string) { // add `>` to each line to include empty lines in the quote const quotedMessage = message.replace(/^/gm, '> '); return quotedMessage + '\n\n'; } function getMostRecentNonLocalMessageID( threadInfo: ThreadInfo, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadInfo.id]; return thread?.messageIDs.find((id) => !id.startsWith('local')); } export { messageKey, messageID, robotextForMessageInfo, robotextToRawString, createMessageInfo, sortMessageInfoList, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, messageTypeGeneratesNotifs, splitRobotext, parseRobotextEntity, usersInMessageInfos, combineTruncationStatuses, shimUnsupportedRawMessageInfos, messagePreviewText, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageReply, getMostRecentNonLocalMessageID, }; diff --git a/lib/shared/notif-utils.js b/lib/shared/notif-utils.js index 3c98015db..366dc6c8a 100644 --- a/lib/shared/notif-utils.js +++ b/lib/shared/notif-utils.js @@ -1,572 +1,557 @@ // @flow import invariant from 'invariant'; import { contentStringForMediaArray } from '../media/media-utils'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type MessageType, messageTypes, } from '../types/message-types'; import type { ThreadInfo, ThreadType } from '../types/thread-types'; import { threadTypes } from '../types/thread-types'; import type { RelativeUserInfo } from '../types/user-types'; import { prettyDate } from '../utils/date-utils'; import { values } from '../utils/objects'; import { pluralize } from '../utils/text-utils'; import { robotextForMessageInfo, robotextToRawString } from './message-utils'; import { threadIsGroupChat, threadNoun } from './thread-utils'; import { stringForUser } from './user-utils'; type NotifTexts = {| merged: string, body: string, title: string, prefix?: string, |}; function notifTextsForMessageInfo( messageInfos: MessageInfo[], threadInfo: ThreadInfo, ): NotifTexts { const fullNotifTexts = fullNotifTextsForMessageInfo(messageInfos, threadInfo); const result: NotifTexts = { merged: trimNotifText(fullNotifTexts.merged, 300), body: trimNotifText(fullNotifTexts.body, 300), title: trimNotifText(fullNotifTexts.title, 100), }; if (fullNotifTexts.prefix) { result.prefix = trimNotifText(fullNotifTexts.prefix, 50); } return result; } function trimNotifText(text: string, maxLength: number): string { if (text.length <= maxLength) { return text; } return text.substr(0, maxLength - 3) + '...'; } const notifTextForSubthreadCreation = ( creator: RelativeUserInfo, threadType: ThreadType, parentThreadInfo: ThreadInfo, childThreadName: ?string, childThreadUIName: string, ) => { const prefix = stringForUser(creator); let body = `created a new ${threadNoun(threadType)}`; if (parentThreadInfo.name) { body += ` in ${parentThreadInfo.name}`; } let merged = `${prefix} ${body}`; if (childThreadName) { merged += ` called "${childThreadName}"`; } return { merged, body, title: childThreadUIName, prefix, }; }; function notifThreadName(threadInfo: ThreadInfo): string { if (threadInfo.name) { return threadInfo.name; } else { return 'your thread'; } } function assertSingleMessageInfo( messageInfos: $ReadOnlyArray, ): MessageInfo { if (messageInfos.length === 0) { throw new Error('expected single MessageInfo, but none present!'); } else if (messageInfos.length !== 1) { const messageIDs = messageInfos.map((messageInfo) => messageInfo.id); console.log( 'expected single MessageInfo, but there are multiple! ' + messageIDs.join(', '), ); } return messageInfos[0]; } function mostRecentMessageInfoType( messageInfos: $ReadOnlyArray, ): MessageType { if (messageInfos.length === 0) { throw new Error('expected MessageInfo, but none present!'); } return messageInfos[0].type; } function fullNotifTextsForMessageInfo( messageInfos: MessageInfo[], threadInfo: ThreadInfo, ): NotifTexts { const mostRecentType = mostRecentMessageInfoType(messageInfos); if (mostRecentType === messageTypes.TEXT) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.TEXT, 'messageInfo should be messageTypes.TEXT!', ); if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { return { merged: `${threadInfo.uiName}: ${messageInfo.text}`, body: messageInfo.text, title: threadInfo.uiName, }; } else { const userString = stringForUser(messageInfo.creator); const threadName = notifThreadName(threadInfo); return { merged: `${userString} to ${threadName}: ${messageInfo.text}`, body: messageInfo.text, title: threadInfo.uiName, prefix: `${userString}:`, }; } } else if (mostRecentType === messageTypes.CREATE_THREAD) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_THREAD, 'messageInfo should be messageTypes.CREATE_THREAD!', ); const parentThreadInfo = messageInfo.initialThreadState.parentThreadInfo; if (parentThreadInfo) { return notifTextForSubthreadCreation( messageInfo.creator, messageInfo.initialThreadState.type, parentThreadInfo, messageInfo.initialThreadState.name, threadInfo.uiName, ); } const prefix = stringForUser(messageInfo.creator); const body = 'created a new thread'; let merged = `${prefix} ${body}`; if (messageInfo.initialThreadState.name) { merged += ` called "${messageInfo.initialThreadState.name}"`; } return { merged, body, title: threadInfo.uiName, prefix, }; } else if (mostRecentType === messageTypes.ADD_MEMBERS) { const addedMembersObject = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.ADD_MEMBERS, 'messageInfo should be messageTypes.ADD_MEMBERS!', ); for (let member of messageInfo.addedMembers) { addedMembersObject[member.id] = member; } } const addedMembers = values(addedMembersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.ADD_MEMBERS, 'messageInfo should be messageTypes.ADD_MEMBERS!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, addedMembers }; const robotext = strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} to ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; } else if (mostRecentType === messageTypes.CREATE_SUB_THREAD) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_SUB_THREAD, 'messageInfo should be messageTypes.CREATE_SUB_THREAD!', ); return notifTextForSubthreadCreation( messageInfo.creator, messageInfo.childThreadInfo.type, threadInfo, messageInfo.childThreadInfo.name, messageInfo.childThreadInfo.uiName, ); } else if (mostRecentType === messageTypes.REMOVE_MEMBERS) { const removedMembersObject = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.REMOVE_MEMBERS, 'messageInfo should be messageTypes.REMOVE_MEMBERS!', ); for (let member of messageInfo.removedMembers) { removedMembersObject[member.id] = member; } } const removedMembers = values(removedMembersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.REMOVE_MEMBERS, 'messageInfo should be messageTypes.REMOVE_MEMBERS!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, removedMembers }; const robotext = strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} from ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; } else if (mostRecentType === messageTypes.CHANGE_ROLE) { const membersObject = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.CHANGE_ROLE, 'messageInfo should be messageTypes.CHANGE_ROLE!', ); for (let member of messageInfo.members) { membersObject[member.id] = member; } } const members = values(membersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.CHANGE_ROLE, 'messageInfo should be messageTypes.CHANGE_ROLE!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, members }; const robotext = strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} from ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; } else if (mostRecentType === messageTypes.LEAVE_THREAD) { const leaverBeavers = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.LEAVE_THREAD, 'messageInfo should be messageTypes.LEAVE_THREAD!', ); leaverBeavers[messageInfo.creator.id] = messageInfo.creator; } const leavers = values(leaverBeavers); const leaversString = pluralize(leavers.map(stringForUser)); const body = `${leaversString} left`; const merged = `${body} ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body, }; } else if (mostRecentType === messageTypes.JOIN_THREAD) { const joinerArray = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.JOIN_THREAD, 'messageInfo should be messageTypes.JOIN_THREAD!', ); joinerArray[messageInfo.creator.id] = messageInfo.creator; } const joiners = values(joinerArray); const joinersString = pluralize(joiners.map(stringForUser)); const body = `${joinersString} joined`; const merged = `${body} ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body, }; } else if ( mostRecentType === messageTypes.CREATE_ENTRY || mostRecentType === messageTypes.EDIT_ENTRY ) { const hasCreateEntry = messageInfos.some( (messageInfo) => messageInfo.type === messageTypes.CREATE_ENTRY, ); const messageInfo = messageInfos[0]; if (!hasCreateEntry) { invariant( messageInfo.type === messageTypes.EDIT_ENTRY, 'messageInfo should be messageTypes.EDIT_ENTRY!', ); const body = `updated the text of an event in ` + `${notifThreadName(threadInfo)} scheduled for ` + `${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const prefix = stringForUser(messageInfo.creator); const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } invariant( messageInfo.type === messageTypes.CREATE_ENTRY || messageInfo.type === messageTypes.EDIT_ENTRY, 'messageInfo should be messageTypes.CREATE_ENTRY/EDIT_ENTRY!', ); const prefix = stringForUser(messageInfo.creator); const body = `created an event in ${notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } else if (mostRecentType === messageTypes.DELETE_ENTRY) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.DELETE_ENTRY, 'messageInfo should be messageTypes.DELETE_ENTRY!', ); const prefix = stringForUser(messageInfo.creator); const body = `deleted an event in ${notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } else if (mostRecentType === messageTypes.RESTORE_ENTRY) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.RESTORE_ENTRY, 'messageInfo should be messageTypes.RESTORE_ENTRY!', ); const prefix = stringForUser(messageInfo.creator); const body = `restored an event in ${notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } else if (mostRecentType === messageTypes.CHANGE_SETTINGS) { const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.CHANGE_SETTINGS, 'messageInfo should be messageTypes.CHANGE_SETTINGS!', ); const body = strippedRobotextForMessageInfo( mostRecentMessageInfo, threadInfo, ); return { merged: body, title: threadInfo.uiName, body, }; } else if ( mostRecentType === messageTypes.IMAGES || mostRecentType === messageTypes.MULTIMEDIA ) { const media = []; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, 'messageInfo should be multimedia type!', ); for (let singleMedia of messageInfo.media) { media.push(singleMedia); } } const contentString = contentStringForMediaArray(media); const userString = stringForUser(messageInfos[0].creator); let body, merged; if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { body = `sent you ${contentString}`; merged = body; } else { body = `sent ${contentString}`; const threadName = notifThreadName(threadInfo); merged = `${body} to ${threadName}`; } merged = `${userString} ${merged}`; return { merged, body, title: threadInfo.uiName, prefix: userString, }; } else if (mostRecentType === messageTypes.UPDATE_RELATIONSHIP) { const messageInfo = assertSingleMessageInfo(messageInfos); const prefix = stringForUser(messageInfo.creator); const title = threadInfo.uiName; const body = messageInfo.operation === 'request_sent' ? 'sent you a friend request' : 'accepted your friend request'; const merged = `${prefix} ${body}`; return { merged, body, title, prefix, }; } else if (mostRecentType === messageTypes.SIDEBAR_SOURCE) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.SIDEBAR_SOURCE, 'messageInfo should be messageTypes.SIDEBAR_SOURCE!', ); - const textMessageInfo = messageInfo.initialMessage; - if (!textMessageInfo.name && !threadIsGroupChat(textMessageInfo)) { - return { - merged: `${threadInfo.uiName}: ${textMessageInfo.text}`, - body: textMessageInfo.text, - title: threadInfo.uiName, - }; - } else { - const userString = stringForUser(textMessageInfo.creator); - const threadName = notifThreadName(threadInfo); - return { - merged: `${userString} to ${threadName}: ${textMessageInfo.text}`, - body: textMessageInfo.text, - title: threadInfo.uiName, - prefix: `${userString}:`, - }; - } + const initialMessageInfo = messageInfo.initialMessage; + return fullNotifTextsForMessageInfo([initialMessageInfo], threadInfo); } else if (mostRecentType === messageTypes.CREATE_SIDEBAR) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_SIDEBAR, 'messageInfo should be messageTypes.CREATE_SIDEBAR!', ); const parentThreadInfo = messageInfo.initialThreadState.parentThreadInfo; return notifTextForSubthreadCreation( messageInfo.creator, threadTypes.SIDEBAR, parentThreadInfo, messageInfo.initialThreadState.name, threadInfo.uiName, ); } else { invariant(false, `we're not aware of messageType ${mostRecentType}`); } } function strippedRobotextForMessageInfo( messageInfo: RobotextMessageInfo, 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 joinResult = (...keys: (string | number)[]) => keys.join('|'); function notifCollapseKeyForRawMessageInfo( rawMessageInfo: RawMessageInfo, ): ?string { if ( rawMessageInfo.type === messageTypes.ADD_MEMBERS || rawMessageInfo.type === messageTypes.REMOVE_MEMBERS ) { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { // We use the legacy constant here to collapse both types into one return joinResult( messageTypes.IMAGES, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); } else if (rawMessageInfo.type === messageTypes.CHANGE_SETTINGS) { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.field, ); } else if (rawMessageInfo.type === messageTypes.CHANGE_ROLE) { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.newRole, ); } else if ( rawMessageInfo.type === messageTypes.JOIN_THREAD || rawMessageInfo.type === messageTypes.LEAVE_THREAD ) { return joinResult(rawMessageInfo.type, rawMessageInfo.threadID); } else if ( rawMessageInfo.type === messageTypes.CREATE_ENTRY || rawMessageInfo.type === messageTypes.EDIT_ENTRY ) { return joinResult(rawMessageInfo.creatorID, rawMessageInfo.entryID); } else { return null; } } type Unmerged = $ReadOnly<{ body: string, title: string, prefix?: string, ... }>; type Merged = {| body: string, title: string, |}; function mergePrefixIntoBody(unmerged: Unmerged): Merged { const { body, title, prefix } = unmerged; const merged = prefix ? `${prefix} ${body}` : body; return { body: merged, title }; } export { notifTextsForMessageInfo, notifCollapseKeyForRawMessageInfo, mergePrefixIntoBody, }; diff --git a/lib/types/message-types.js b/lib/types/message-types.js index c2d031827..18232de96 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,907 +1,909 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import type { FetchResultInfoInterface } from '../utils/fetch-json'; import { type Media, type Image, mediaPropType } from './media-types'; import { type ThreadInfo, threadInfoPropType, type ThreadType, threadTypePropType, } from './thread-types'; import { type RelativeUserInfo, relativeUserInfoPropType, type UserInfos, } from './user-types'; export const messageTypes = Object.freeze({ TEXT: 0, CREATE_THREAD: 1, ADD_MEMBERS: 2, CREATE_SUB_THREAD: 3, CHANGE_SETTINGS: 4, REMOVE_MEMBERS: 5, CHANGE_ROLE: 6, LEAVE_THREAD: 7, JOIN_THREAD: 8, CREATE_ENTRY: 9, EDIT_ENTRY: 10, DELETE_ENTRY: 11, RESTORE_ENTRY: 12, // When the server has a message to deliver that the client can't properly // render because the client is too old, the server will send this message // type instead. Consequently, there is no MessageData for UNSUPPORTED - just // a RawMessageInfo and a MessageInfo. Note that native/persist.js handles // converting these MessageInfos when the client is upgraded. UNSUPPORTED: 13, IMAGES: 14, MULTIMEDIA: 15, UPDATE_RELATIONSHIP: 16, SIDEBAR_SOURCE: 17, CREATE_SIDEBAR: 18, }); export type MessageType = $Values; export function assertMessageType(ourMessageType: number): MessageType { invariant( ourMessageType === 0 || ourMessageType === 1 || ourMessageType === 2 || ourMessageType === 3 || ourMessageType === 4 || ourMessageType === 5 || ourMessageType === 6 || ourMessageType === 7 || ourMessageType === 8 || ourMessageType === 9 || ourMessageType === 10 || ourMessageType === 11 || ourMessageType === 12 || ourMessageType === 13 || ourMessageType === 14 || ourMessageType === 15 || ourMessageType === 16 || ourMessageType === 17 || ourMessageType === 18, 'number is not MessageType enum', ); return ourMessageType; } const composableMessageTypes = new Set([ messageTypes.TEXT, messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isComposableMessageType(ourMessageType: MessageType): boolean { return composableMessageTypes.has(ourMessageType); } export function assertComposableMessageType( ourMessageType: MessageType, ): MessageType { invariant( isComposableMessageType(ourMessageType), 'MessageType is not composed', ); return ourMessageType; } export function messageDataLocalID(messageData: MessageData) { if ( messageData.type !== messageTypes.TEXT && messageData.type !== messageTypes.IMAGES && messageData.type !== messageTypes.MULTIMEDIA ) { return null; } return messageData.localID; } const mediaMessageTypes = new Set([ messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isMediaMessageType(ourMessageType: MessageType): boolean { return mediaMessageTypes.has(ourMessageType); } export function assetMediaMessageType( ourMessageType: MessageType, ): MessageType { invariant(isMediaMessageType(ourMessageType), 'MessageType is not media'); return ourMessageType; } // *MessageData = passed to createMessages function to insert into database // Raw*MessageInfo = used by server, and contained in client's local store // *MessageInfo = used by client in UI code export type TextMessageData = {| type: 0, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, text: string, |}; type CreateThreadMessageData = {| type: 1, threadID: string, creatorID: string, time: number, initialThreadState: {| type: ThreadType, name: ?string, parentThreadID: ?string, color: string, memberIDs: string[], |}, |}; type AddMembersMessageData = {| type: 2, threadID: string, creatorID: string, time: number, addedUserIDs: string[], |}; type CreateSubthreadMessageData = {| type: 3, threadID: string, creatorID: string, time: number, childThreadID: string, |}; type ChangeSettingsMessageData = {| type: 4, threadID: string, creatorID: string, time: number, field: string, value: string | number, |}; type RemoveMembersMessageData = {| type: 5, threadID: string, creatorID: string, time: number, removedUserIDs: string[], |}; type ChangeRoleMessageData = {| type: 6, threadID: string, creatorID: string, time: number, userIDs: string[], newRole: string, |}; type LeaveThreadMessageData = {| type: 7, threadID: string, creatorID: string, time: number, |}; type JoinThreadMessageData = {| type: 8, threadID: string, creatorID: string, time: number, |}; type CreateEntryMessageData = {| type: 9, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type EditEntryMessageData = {| type: 10, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type DeleteEntryMessageData = {| type: 11, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type RestoreEntryMessageData = {| type: 12, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; export type ImagesMessageData = {| type: 14, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, media: $ReadOnlyArray, |}; export type MediaMessageData = {| type: 15, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, media: $ReadOnlyArray, |}; export type UpdateRelationshipMessageData = {| +type: 16, +threadID: string, +creatorID: string, +targetID: string, +time: number, +operation: 'request_sent' | 'request_accepted', |}; export type SidebarSourceMessageData = {| +type: 17, +threadID: string, +creatorID: string, +time: number, - +initialMessage: RawTextMessageInfo, + +initialMessage: RawComposableMessageInfo | RawRobotextMessageInfo, |}; export type CreateSidebarMessageData = {| +type: 18, +threadID: string, +creatorID: string, +time: number, +initialThreadState: {| +name: ?string, +parentThreadID: string, +color: string, +memberIDs: string[], |}, |}; export type MessageData = | TextMessageData | CreateThreadMessageData | AddMembersMessageData | CreateSubthreadMessageData | ChangeSettingsMessageData | RemoveMembersMessageData | ChangeRoleMessageData | LeaveThreadMessageData | JoinThreadMessageData | CreateEntryMessageData | EditEntryMessageData | DeleteEntryMessageData | RestoreEntryMessageData | ImagesMessageData | MediaMessageData | UpdateRelationshipMessageData | SidebarSourceMessageData | CreateSidebarMessageData; export type MultimediaMessageData = ImagesMessageData | MediaMessageData; export type RawTextMessageInfo = {| ...TextMessageData, id?: string, // null if local copy without ID yet |}; export type RawImagesMessageInfo = {| ...ImagesMessageData, id?: string, // null if local copy without ID yet |}; export type RawMediaMessageInfo = {| ...MediaMessageData, id?: string, // null if local copy without ID yet |}; export type RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; type RawRobotextMessageInfo = | {| ...CreateThreadMessageData, id: string, |} | {| ...AddMembersMessageData, id: string, |} | {| ...CreateSubthreadMessageData, id: string, |} | {| ...ChangeSettingsMessageData, id: string, |} | {| ...RemoveMembersMessageData, id: string, |} | {| ...ChangeRoleMessageData, id: string, |} | {| ...LeaveThreadMessageData, id: string, |} | {| ...JoinThreadMessageData, id: string, |} | {| ...CreateEntryMessageData, id: string, |} | {| ...EditEntryMessageData, id: string, |} | {| ...DeleteEntryMessageData, id: string, |} | {| ...RestoreEntryMessageData, id: string, |} | {| ...UpdateRelationshipMessageData, id: string, |} | {| ...CreateSidebarMessageData, id: string, |} | {| type: 13, id: string, threadID: string, creatorID: string, time: number, robotext: string, unsupportedMessageInfo: Object, |}; export type RawSidebarSourceMessageInfo = {| ...SidebarSourceMessageData, id: string, |}; export type RawMessageInfo = | RawComposableMessageInfo | RawRobotextMessageInfo | RawSidebarSourceMessageInfo; export type LocallyComposedMessageInfo = { localID: string, threadID: string, ... }; export type TextMessageInfo = {| type: 0, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp text: string, |}; export type ImagesMessageInfo = {| type: 14, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp media: $ReadOnlyArray, |}; export type MediaMessageInfo = {| type: 15, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp media: $ReadOnlyArray, |}; export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo; export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo; export type RobotextMessageInfo = | {| type: 1, id: string, threadID: string, creator: RelativeUserInfo, time: number, initialThreadState: {| type: ThreadType, name: ?string, parentThreadInfo: ?ThreadInfo, color: string, otherMembers: RelativeUserInfo[], |}, |} | {| type: 2, id: string, threadID: string, creator: RelativeUserInfo, time: number, addedMembers: RelativeUserInfo[], |} | {| type: 3, id: string, threadID: string, creator: RelativeUserInfo, time: number, childThreadInfo: ThreadInfo, |} | {| type: 4, id: string, threadID: string, creator: RelativeUserInfo, time: number, field: string, value: string | number, |} | {| type: 5, id: string, threadID: string, creator: RelativeUserInfo, time: number, removedMembers: RelativeUserInfo[], |} | {| type: 6, id: string, threadID: string, creator: RelativeUserInfo, time: number, members: RelativeUserInfo[], newRole: string, |} | {| type: 7, id: string, threadID: string, creator: RelativeUserInfo, time: number, |} | {| type: 8, id: string, threadID: string, creator: RelativeUserInfo, time: number, |} | {| type: 9, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 10, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 11, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 12, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 13, id: string, threadID: string, creator: RelativeUserInfo, time: number, robotext: string, unsupportedMessageInfo: Object, |} | {| +type: 16, +id: string, +threadID: string, +creator: RelativeUserInfo, +target: RelativeUserInfo, +time: number, +operation: 'request_sent' | 'request_accepted', |} | {| +type: 18, +id: string, +threadID: string, +creator: RelativeUserInfo, +time: number, +initialThreadState: {| +name: ?string, +parentThreadInfo: ThreadInfo, +color: string, +otherMembers: RelativeUserInfo[], |}, |}; export type PreviewableMessageInfo = | RobotextMessageInfo | MultimediaMessageInfo; export type SidebarSourceMessageInfo = {| +type: 17, +id: string, +threadID: string, +creator: RelativeUserInfo, +time: number, - +initialMessage: TextMessageInfo, + +initialMessage: ComposableMessageInfo | RobotextMessageInfo, |}; export type MessageInfo = | ComposableMessageInfo | RobotextMessageInfo | SidebarSourceMessageInfo; -export const textMessagePropType = PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.TEXT]).isRequired, - id: PropTypes.string, - localID: PropTypes.string, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - text: PropTypes.string.isRequired, -}); - -export const messageInfoPropType = PropTypes.oneOfType([ - textMessagePropType, +const messageInfoPropTypes = [ + PropTypes.shape({ + type: PropTypes.oneOf([messageTypes.TEXT]).isRequired, + id: PropTypes.string, + localID: PropTypes.string, + threadID: PropTypes.string.isRequired, + creator: relativeUserInfoPropType.isRequired, + time: PropTypes.number.isRequired, + text: PropTypes.string.isRequired, + }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, initialThreadState: PropTypes.shape({ type: threadTypePropType.isRequired, name: PropTypes.string, parentThreadInfo: threadInfoPropType, color: PropTypes.string.isRequired, otherMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.ADD_MEMBERS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, addedMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_SUB_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, childThreadInfo: threadInfoPropType.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CHANGE_SETTINGS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, field: PropTypes.string.isRequired, value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.REMOVE_MEMBERS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, removedMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CHANGE_ROLE]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, members: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, newRole: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.LEAVE_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.JOIN_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.EDIT_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.DELETE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.RESTORE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.UNSUPPORTED]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, robotext: PropTypes.string.isRequired, unsupportedMessageInfo: PropTypes.object.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.IMAGES]).isRequired, id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, media: PropTypes.arrayOf(mediaPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.MULTIMEDIA]).isRequired, id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, media: PropTypes.arrayOf(mediaPropType).isRequired, }), PropTypes.exact({ type: PropTypes.oneOf([messageTypes.UPDATE_RELATIONSHIP]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, target: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, operation: PropTypes.oneOf(['request_sent', 'request_accepted']), }), - PropTypes.exact({ - type: PropTypes.oneOf([messageTypes.SIDEBAR_SOURCE]).isRequired, - id: PropTypes.string.isRequired, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - initialMessage: textMessagePropType.isRequired, - }), PropTypes.exact({ type: PropTypes.oneOf([messageTypes.CREATE_SIDEBAR]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, initialThreadState: PropTypes.shape({ name: PropTypes.string, parentThreadInfo: threadInfoPropType.isRequired, color: PropTypes.string.isRequired, otherMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }).isRequired, }), +]; + +export const messageInfoPropType = PropTypes.oneOfType([ + ...messageInfoPropTypes, + PropTypes.exact({ + type: PropTypes.oneOf([messageTypes.SIDEBAR_SOURCE]).isRequired, + id: PropTypes.string.isRequired, + threadID: PropTypes.string.isRequired, + creator: relativeUserInfoPropType.isRequired, + time: PropTypes.number.isRequired, + initialMessage: PropTypes.oneOfType(messageInfoPropTypes).isRequired, + }), ]); export type ThreadMessageInfo = {| messageIDs: string[], startReached: boolean, lastNavigatedTo: number, // millisecond timestamp lastPruned: number, // millisecond timestamp |}; // Tracks client-local information about a message that hasn't been assigned an // ID by the server yet. As soon as the client gets an ack from the server for // this message, it will clear the LocalMessageInfo. export type LocalMessageInfo = {| sendFailed?: boolean, |}; export const localMessageInfoPropType = PropTypes.shape({ sendFailed: PropTypes.bool, }); export type MessageStore = {| messages: { [id: string]: RawMessageInfo }, threads: { [threadID: string]: ThreadMessageInfo }, local: { [id: string]: LocalMessageInfo }, currentAsOf: number, |}; export const messageTruncationStatus = Object.freeze({ // EXHAUSTIVE means we've reached the start of the thread. Either the result // set includes the very first message for that thread, or there is nothing // behind the cursor you queried for. Given that the client only ever issues // ranged queries whose range, when unioned with what is in state, represent // the set of all messages for a given thread, we can guarantee that getting // EXHAUSTIVE means the start has been reached. EXHAUSTIVE: 'exhaustive', // TRUNCATED is rare, and means that the server can't guarantee that the // result set for a given thread is contiguous with what the client has in its // state. If the client can't verify the contiguousness itself, it needs to // replace its Redux store's contents with what it is in this payload. // 1) getMessageInfosSince: Result set for thread is equal to max, and the // truncation status isn't EXHAUSTIVE (ie. doesn't include the very first // message). // 2) getMessageInfos: ThreadSelectionCriteria does not specify cursors, the // result set for thread is equal to max, and the truncation status isn't // EXHAUSTIVE. If cursors are specified, we never return truncated, since // the cursor given us guarantees the contiguousness of the result set. // Note that in the reducer, we can guarantee contiguousness if there is any // intersection between messageIDs in the result set and the set currently in // the Redux store. TRUNCATED: 'truncated', // UNCHANGED means the result set is guaranteed to be contiguous with what the // client has in its state, but is not EXHAUSTIVE. Basically, it's anything // that isn't either EXHAUSTIVE or TRUNCATED. UNCHANGED: 'unchanged', }); export type MessageTruncationStatus = $Values; export function assertMessageTruncationStatus( ourMessageTruncationStatus: string, ): MessageTruncationStatus { invariant( ourMessageTruncationStatus === 'truncated' || ourMessageTruncationStatus === 'unchanged' || ourMessageTruncationStatus === 'exhaustive', 'string is not ourMessageTruncationStatus enum', ); return ourMessageTruncationStatus; } export type MessageTruncationStatuses = { [threadID: string]: MessageTruncationStatus, }; export type ThreadCursors = { [threadID: string]: ?string }; export type ThreadSelectionCriteria = {| threadCursors?: ?ThreadCursors, joinedThreads?: ?boolean, |}; export type FetchMessageInfosRequest = {| cursors: ThreadCursors, numberPerThread?: ?number, |}; export type FetchMessageInfosResponse = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, userInfos: UserInfos, |}; export type FetchMessageInfosResult = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, |}; export type FetchMessageInfosPayload = {| threadID: string, rawMessageInfos: RawMessageInfo[], truncationStatus: MessageTruncationStatus, |}; export type MessagesResponse = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, currentAsOf: number, |}; export const defaultNumberPerThread = 20; export type SendMessageResponse = {| +newMessageInfo: RawMessageInfo, |}; export type SendMessageResult = {| +id: string, +time: number, +interface: FetchResultInfoInterface, |}; export type SendMessagePayload = {| +localID: string, +serverID: string, +threadID: string, +time: number, +interface: FetchResultInfoInterface, |}; export type SendTextMessageRequest = {| threadID: string, localID?: string, text: string, |}; export type SendMultimediaMessageRequest = {| threadID: string, localID: string, mediaIDs: $ReadOnlyArray, |}; // Used for the message info included in log-in type actions export type GenericMessagesResult = {| messageInfos: RawMessageInfo[], truncationStatus: MessageTruncationStatuses, watchedIDsAtRequestTime: $ReadOnlyArray, currentAsOf: number, |}; export type SaveMessagesPayload = {| rawMessageInfos: $ReadOnlyArray, updatesCurrentAsOf: number, |}; export type NewMessagesPayload = {| messagesResult: MessagesResponse, |}; export type MessageStorePrunePayload = {| threadIDs: $ReadOnlyArray, |};