diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index 23a7b78b1..5f9765532 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,1109 +1,1112 @@ // @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 RawMultimediaMessageInfo, type MessageData, type MessageType, type MessageTruncationStatus, type MultimediaMessageData, type MessageStore, messageTypes, messageTruncationStatus, } from '../types/message-types'; import type { CreateSidebarMessageInfo } from '../types/message/create-sidebar'; import type { CreateThreadMessageInfo } from '../types/message/create-thread'; import type { ImagesMessageData, ImagesMessageInfo, RawImagesMessageInfo, } from '../types/message/images'; import type { MediaMessageData, MediaMessageInfo, RawMediaMessageInfo, } from '../types/message/media'; import type { TextMessageInfo } from '../types/message/text'; 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: CreateThreadMessageInfo, creator: string, ) { let text = `created ${encodedThreadEntity( messageInfo.threadID, `this thread`, )}`; 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 newSidebarRobotext( messageInfo: CreateSidebarMessageInfo, creator: string, ) { let text = `started ${encodedThreadEntity( messageInfo.threadID, `this sidebar`, )}`; const users = messageInfo.initialThreadState.otherMembers.filter( (member) => member.id !== messageInfo.sourceMessageAuthor.id, ); 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 newSidebarRobotext(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) { + if (!rawMessageInfo.sourceMessage) { + return null; + } const sourceMessage = createMessageInfo( rawMessageInfo.sourceMessage, viewerID, userInfos, threadInfos, ); invariant( sourceMessage && sourceMessage.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, sourceMessage, }; } else if (rawMessageInfo.type === messageTypes.CREATE_SIDEBAR) { const parentThreadInfo = threadInfos[rawMessageInfo.initialThreadState.parentThreadID]; const sourceMessageAuthor = userInfos[rawMessageInfo.sourceMessageAuthorID]; if (!sourceMessageAuthor) { return null; } 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, sourceMessageAuthor: { id: rawMessageInfo.sourceMessageAuthorID, username: sourceMessageAuthor.username, isViewer: rawMessageInfo.sourceMessageAuthorID === viewerID, }, 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) && rawMessageInfo.sourceMessage ) { 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/messages/add-members-message-spec.js b/lib/shared/messages/add-members-message-spec.js index 5b9fab377..884206a4a 100644 --- a/lib/shared/messages/add-members-message-spec.js +++ b/lib/shared/messages/add-members-message-spec.js @@ -1,12 +1,28 @@ // @flow -import type { AddMembersMessageData } from '../../types/message/add-members'; +import { messageTypes } from '../../types/message-types'; +import type { + AddMembersMessageData, + RawAddMembersMessageInfo, +} from '../../types/message/add-members'; import type { MessageSpec } from './message-spec'; -export const addMembersMessageSpec: MessageSpec = Object.freeze( - { - messageContent(data) { - return JSON.stringify(data.addedUserIDs); - }, +export const addMembersMessageSpec: MessageSpec< + AddMembersMessageData, + RawAddMembersMessageInfo, +> = Object.freeze({ + messageContent(data) { + return JSON.stringify(data.addedUserIDs); }, -); + + rawMessageInfoFromRow(row) { + return { + type: messageTypes.ADD_MEMBERS, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + addedUserIDs: JSON.parse(row.content), + }; + }, +}); diff --git a/lib/shared/messages/change-role-message-spec.js b/lib/shared/messages/change-role-message-spec.js index c501b7bb3..c94d885b6 100644 --- a/lib/shared/messages/change-role-message-spec.js +++ b/lib/shared/messages/change-role-message-spec.js @@ -1,15 +1,33 @@ // @flow -import type { ChangeRoleMessageData } from '../../types/message/change-role'; +import { messageTypes } from '../../types/message-types'; +import type { + ChangeRoleMessageData, + RawChangeRoleMessageInfo, +} from '../../types/message/change-role'; import type { MessageSpec } from './message-spec'; -export const changeRoleMessageSpec: MessageSpec = Object.freeze( - { - messageContent(data) { - return JSON.stringify({ - userIDs: data.userIDs, - newRole: data.newRole, - }); - }, +export const changeRoleMessageSpec: MessageSpec< + ChangeRoleMessageData, + RawChangeRoleMessageInfo, +> = Object.freeze({ + messageContent(data) { + return JSON.stringify({ + userIDs: data.userIDs, + newRole: data.newRole, + }); }, -); + + rawMessageInfoFromRow(row) { + const content = JSON.parse(row.content); + return { + type: messageTypes.CHANGE_ROLE, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + userIDs: content.userIDs, + newRole: content.newRole, + }; + }, +}); diff --git a/lib/shared/messages/change-settings-message-spec.js b/lib/shared/messages/change-settings-message-spec.js index f224b7c95..a842d95aa 100644 --- a/lib/shared/messages/change-settings-message-spec.js +++ b/lib/shared/messages/change-settings-message-spec.js @@ -1,14 +1,33 @@ // @flow -import type { ChangeSettingsMessageData } from '../../types/message/change-settings'; +import { messageTypes } from '../../types/message-types'; +import type { + ChangeSettingsMessageData, + RawChangeSettingsMessageInfo, +} from '../../types/message/change-settings'; import type { MessageSpec } from './message-spec'; -export const changeSettingsMessageSpec: MessageSpec = Object.freeze( - { - messageContent(data) { - return JSON.stringify({ - [data.field]: data.value, - }); - }, +export const changeSettingsMessageSpec: MessageSpec< + ChangeSettingsMessageData, + RawChangeSettingsMessageInfo, +> = Object.freeze({ + messageContent(data) { + return JSON.stringify({ + [data.field]: data.value, + }); }, -); + + rawMessageInfoFromRow(row) { + const content = JSON.parse(row.content); + const field = Object.keys(content)[0]; + return { + type: messageTypes.CHANGE_SETTINGS, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + field, + value: content[field], + }; + }, +}); diff --git a/lib/shared/messages/create-entry-message-spec.js b/lib/shared/messages/create-entry-message-spec.js index dbcc75db3..053a245b3 100644 --- a/lib/shared/messages/create-entry-message-spec.js +++ b/lib/shared/messages/create-entry-message-spec.js @@ -1,16 +1,35 @@ // @flow -import type { CreateEntryMessageData } from '../../types/message/create-entry'; +import { messageTypes } from '../../types/message-types'; +import type { + CreateEntryMessageData, + RawCreateEntryMessageInfo, +} from '../../types/message/create-entry'; import type { MessageSpec } from './message-spec'; -export const createEntryMessageSpec: MessageSpec = Object.freeze( - { - messageContent(data) { - return JSON.stringify({ - entryID: data.entryID, - date: data.date, - text: data.text, - }); - }, +export const createEntryMessageSpec: MessageSpec< + CreateEntryMessageData, + RawCreateEntryMessageInfo, +> = Object.freeze({ + messageContent(data) { + return JSON.stringify({ + entryID: data.entryID, + date: data.date, + text: data.text, + }); }, -); + + rawMessageInfoFromRow(row) { + const content = JSON.parse(row.content); + return { + type: messageTypes.CREATE_ENTRY, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + entryID: content.entryID, + date: content.date, + text: content.text, + }; + }, +}); diff --git a/lib/shared/messages/create-sidebar-message-spec.js b/lib/shared/messages/create-sidebar-message-spec.js index 11038a05a..90f9a21f0 100644 --- a/lib/shared/messages/create-sidebar-message-spec.js +++ b/lib/shared/messages/create-sidebar-message-spec.js @@ -1,15 +1,35 @@ // @flow -import type { CreateSidebarMessageData } from '../../types/message/create-sidebar'; +import { messageTypes } from '../../types/message-types'; +import type { + CreateSidebarMessageData, + RawCreateSidebarMessageInfo, +} from '../../types/message/create-sidebar'; import type { MessageSpec } from './message-spec'; -export const createSidebarMessageSpec: MessageSpec = Object.freeze( - { - messageContent(data) { - return JSON.stringify({ - ...data.initialThreadState, - sourceMessageAuthorID: data.sourceMessageAuthorID, - }); - }, +export const createSidebarMessageSpec: MessageSpec< + CreateSidebarMessageData, + RawCreateSidebarMessageInfo, +> = Object.freeze({ + messageContent(data) { + return JSON.stringify({ + ...data.initialThreadState, + sourceMessageAuthorID: data.sourceMessageAuthorID, + }); }, -); + + rawMessageInfoFromRow(row) { + const { sourceMessageAuthorID, ...initialThreadState } = JSON.parse( + row.content, + ); + return { + type: messageTypes.CREATE_SIDEBAR, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + sourceMessageAuthorID, + initialThreadState, + }; + }, +}); diff --git a/lib/shared/messages/create-sub-thread-message-spec.js b/lib/shared/messages/create-sub-thread-message-spec.js index d4e69f684..ad17426f1 100644 --- a/lib/shared/messages/create-sub-thread-message-spec.js +++ b/lib/shared/messages/create-sub-thread-message-spec.js @@ -1,12 +1,34 @@ // @flow -import type { CreateSubthreadMessageData } from '../../types/message/create-subthread'; +import { permissionLookup } from '../../permissions/thread-permissions'; +import { messageTypes } from '../../types/message-types'; +import type { + CreateSubthreadMessageData, + RawCreateSubthreadMessageInfo, +} from '../../types/message/create-subthread'; +import { threadPermissions } from '../../types/thread-types'; import type { MessageSpec } from './message-spec'; -export const createSubThreadMessageSpec: MessageSpec = Object.freeze( - { - messageContent(data) { - return data.childThreadID; - }, +export const createSubThreadMessageSpec: MessageSpec< + CreateSubthreadMessageData, + RawCreateSubthreadMessageInfo, +> = Object.freeze({ + messageContent(data) { + return data.childThreadID; }, -); + + rawMessageInfoFromRow(row) { + const subthreadPermissions = row.subthread_permissions; + if (!permissionLookup(subthreadPermissions, threadPermissions.KNOW_OF)) { + return null; + } + return { + type: messageTypes.CREATE_SUB_THREAD, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + childThreadID: row.content, + }; + }, +}); diff --git a/lib/shared/messages/create-thread-message-spec.js b/lib/shared/messages/create-thread-message-spec.js index ed40d2858..0558fb508 100644 --- a/lib/shared/messages/create-thread-message-spec.js +++ b/lib/shared/messages/create-thread-message-spec.js @@ -1,12 +1,28 @@ // @flow -import type { CreateThreadMessageData } from '../../types/message/create-thread'; +import { messageTypes } from '../../types/message-types'; +import type { + CreateThreadMessageData, + RawCreateThreadMessageInfo, +} from '../../types/message/create-thread'; import type { MessageSpec } from './message-spec'; -export const createThreadMessageSpec: MessageSpec = Object.freeze( - { - messageContent(data) { - return JSON.stringify(data.initialThreadState); - }, +export const createThreadMessageSpec: MessageSpec< + CreateThreadMessageData, + RawCreateThreadMessageInfo, +> = Object.freeze({ + messageContent(data) { + return JSON.stringify(data.initialThreadState); }, -); + + rawMessageInfoFromRow(row) { + return { + type: messageTypes.CREATE_THREAD, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + initialThreadState: JSON.parse(row.content), + }; + }, +}); diff --git a/lib/shared/messages/delete-entry-message-spec.js b/lib/shared/messages/delete-entry-message-spec.js index 4ae8920f1..abd376148 100644 --- a/lib/shared/messages/delete-entry-message-spec.js +++ b/lib/shared/messages/delete-entry-message-spec.js @@ -1,16 +1,35 @@ // @flow -import type { DeleteEntryMessageData } from '../../types/message/delete-entry'; +import { messageTypes } from '../../types/message-types'; +import type { + DeleteEntryMessageData, + RawDeleteEntryMessageInfo, +} from '../../types/message/delete-entry'; import type { MessageSpec } from './message-spec'; -export const deleteEntryMessageSpec: MessageSpec = Object.freeze( - { - messageContent(data) { - return JSON.stringify({ - entryID: data.entryID, - date: data.date, - text: data.text, - }); - }, +export const deleteEntryMessageSpec: MessageSpec< + DeleteEntryMessageData, + RawDeleteEntryMessageInfo, +> = Object.freeze({ + messageContent(data) { + return JSON.stringify({ + entryID: data.entryID, + date: data.date, + text: data.text, + }); }, -); + + rawMessageInfoFromRow(row) { + const content = JSON.parse(row.content); + return { + type: messageTypes.DELETE_ENTRY, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + entryID: content.entryID, + date: content.date, + text: content.text, + }; + }, +}); diff --git a/lib/shared/messages/edit-entry-message-spec.js b/lib/shared/messages/edit-entry-message-spec.js index 10a27ef2a..0f4892e48 100644 --- a/lib/shared/messages/edit-entry-message-spec.js +++ b/lib/shared/messages/edit-entry-message-spec.js @@ -1,16 +1,35 @@ // @flow -import type { EditEntryMessageData } from '../../types/message/edit-entry'; +import { messageTypes } from '../../types/message-types'; +import type { + EditEntryMessageData, + RawEditEntryMessageInfo, +} from '../../types/message/edit-entry'; import type { MessageSpec } from './message-spec'; -export const editEntryMessageSpec: MessageSpec = Object.freeze( - { - messageContent(data) { - return JSON.stringify({ - entryID: data.entryID, - date: data.date, - text: data.text, - }); - }, +export const editEntryMessageSpec: MessageSpec< + EditEntryMessageData, + RawEditEntryMessageInfo, +> = Object.freeze({ + messageContent(data) { + return JSON.stringify({ + entryID: data.entryID, + date: data.date, + text: data.text, + }); }, -); + + rawMessageInfoFromRow(row) { + const content = JSON.parse(row.content); + return { + type: messageTypes.EDIT_ENTRY, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + entryID: content.entryID, + date: content.date, + text: content.text, + }; + }, +}); diff --git a/lib/shared/messages/images-message-spec.js b/lib/shared/messages/images-message-spec.js deleted file mode 100644 index 1cfe63bc4..000000000 --- a/lib/shared/messages/images-message-spec.js +++ /dev/null @@ -1,11 +0,0 @@ -// @flow - -import type { ImagesMessageData } from '../../types/message/images'; -import type { MessageSpec } from './message-spec'; - -export const imagesMessageSpec: MessageSpec = Object.freeze({ - messageContent(data) { - const mediaIDs = data.media.map((media) => parseInt(media.id, 10)); - return JSON.stringify(mediaIDs); - }, -}); diff --git a/lib/shared/messages/join-thread-message-spec.js b/lib/shared/messages/join-thread-message-spec.js index 004c61055..231ce7368 100644 --- a/lib/shared/messages/join-thread-message-spec.js +++ b/lib/shared/messages/join-thread-message-spec.js @@ -1,8 +1,23 @@ // @flow -import type { JoinThreadMessageData } from '../../types/message/join-thread'; +import { messageTypes } from '../../types/message-types'; +import type { + JoinThreadMessageData, + RawJoinThreadMessageInfo, +} from '../../types/message/join-thread'; import type { MessageSpec } from './message-spec'; -export const joinThreadMessageSpec: MessageSpec = Object.freeze( - {}, -); +export const joinThreadMessageSpec: MessageSpec< + JoinThreadMessageData, + RawJoinThreadMessageInfo, +> = Object.freeze({ + rawMessageInfoFromRow(row) { + return { + type: messageTypes.JOIN_THREAD, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + }; + }, +}); diff --git a/lib/shared/messages/leave-thread-message-spec.js b/lib/shared/messages/leave-thread-message-spec.js index e38480311..919f8b2ff 100644 --- a/lib/shared/messages/leave-thread-message-spec.js +++ b/lib/shared/messages/leave-thread-message-spec.js @@ -1,8 +1,23 @@ // @flow -import type { LeaveThreadMessageData } from '../../types/message/leave-thread'; +import { messageTypes } from '../../types/message-types'; +import type { + LeaveThreadMessageData, + RawLeaveThreadMessageInfo, +} from '../../types/message/leave-thread'; import type { MessageSpec } from './message-spec'; -export const leaveThreadMessageSpec: MessageSpec = Object.freeze( - {}, -); +export const leaveThreadMessageSpec: MessageSpec< + LeaveThreadMessageData, + RawLeaveThreadMessageInfo, +> = Object.freeze({ + rawMessageInfoFromRow(row) { + return { + type: messageTypes.LEAVE_THREAD, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + }; + }, +}); diff --git a/lib/shared/messages/message-spec.js b/lib/shared/messages/message-spec.js index 18685e04e..fe40ada1b 100644 --- a/lib/shared/messages/message-spec.js +++ b/lib/shared/messages/message-spec.js @@ -1,5 +1,22 @@ // @flow -export type MessageSpec = {| +import type { Media } from '../../types/media-types'; +import type { + RawComposableMessageInfo, + RawRobotextMessageInfo, +} from '../../types/message-types'; + +export type MessageSpec = {| +messageContent?: (data: Data) => string | null, + +rawMessageInfoFromRow?: ( + row: Object, + params: {| + +localID: string, + +media?: $ReadOnlyArray, + +derivedMessages: $ReadOnlyMap< + string, + RawComposableMessageInfo | RawRobotextMessageInfo, + >, + |}, + ) => ?RawInfo, |}; diff --git a/lib/shared/messages/message-specs.js b/lib/shared/messages/message-specs.js index 1687aea51..e7f146e8d 100644 --- a/lib/shared/messages/message-specs.js +++ b/lib/shared/messages/message-specs.js @@ -1,44 +1,43 @@ // @flow import { messageTypes } from '../../types/message-types'; import { addMembersMessageSpec } from './add-members-message-spec'; import { changeRoleMessageSpec } from './change-role-message-spec'; import { changeSettingsMessageSpec } from './change-settings-message-spec'; import { createEntryMessageSpec } from './create-entry-message-spec'; import { createSidebarMessageSpec } from './create-sidebar-message-spec'; import { createSubThreadMessageSpec } from './create-sub-thread-message-spec'; import { createThreadMessageSpec } from './create-thread-message-spec'; import { deleteEntryMessageSpec } from './delete-entry-message-spec'; import { editEntryMessageSpec } from './edit-entry-message-spec'; -import { imagesMessageSpec } from './images-message-spec'; import { joinThreadMessageSpec } from './join-thread-message-spec'; import { leaveThreadMessageSpec } from './leave-thread-message-spec'; import { multimediaMessageSpec } from './multimedia-message-spec'; import { removeMembersMessageSpec } from './remove-members-message-spec'; import { restoreEntryMessageSpec } from './restore-entry-message-spec'; import { sidebarSourceMessageSpec } from './sidebar-source-message-spec'; import { textMessageSpec } from './text-message-spec'; import { unsupportedMessageSpec } from './unsupported-message-spec'; import { updateRelationshipMessageSpec } from './update-relationship-message-spec'; export const messageSpecs = Object.freeze({ [messageTypes.TEXT]: textMessageSpec, [messageTypes.CREATE_THREAD]: createThreadMessageSpec, [messageTypes.ADD_MEMBERS]: addMembersMessageSpec, [messageTypes.CREATE_SUB_THREAD]: createSubThreadMessageSpec, [messageTypes.CHANGE_SETTINGS]: changeSettingsMessageSpec, [messageTypes.REMOVE_MEMBERS]: removeMembersMessageSpec, [messageTypes.CHANGE_ROLE]: changeRoleMessageSpec, [messageTypes.LEAVE_THREAD]: leaveThreadMessageSpec, [messageTypes.JOIN_THREAD]: joinThreadMessageSpec, [messageTypes.CREATE_ENTRY]: createEntryMessageSpec, [messageTypes.EDIT_ENTRY]: editEntryMessageSpec, [messageTypes.DELETE_ENTRY]: deleteEntryMessageSpec, [messageTypes.RESTORE_ENTRY]: restoreEntryMessageSpec, [messageTypes.UNSUPPORTED]: unsupportedMessageSpec, - [messageTypes.IMAGES]: imagesMessageSpec, + [messageTypes.IMAGES]: multimediaMessageSpec, [messageTypes.MULTIMEDIA]: multimediaMessageSpec, [messageTypes.UPDATE_RELATIONSHIP]: updateRelationshipMessageSpec, [messageTypes.SIDEBAR_SOURCE]: sidebarSourceMessageSpec, [messageTypes.CREATE_SIDEBAR]: createSidebarMessageSpec, }); diff --git a/lib/shared/messages/multimedia-message-spec.js b/lib/shared/messages/multimedia-message-spec.js index 42dba6621..ee1677879 100644 --- a/lib/shared/messages/multimedia-message-spec.js +++ b/lib/shared/messages/multimedia-message-spec.js @@ -1,13 +1,37 @@ // @flow -import type { MediaMessageData } from '../../types/message/media'; +import invariant from 'invariant'; + +import type { + ImagesMessageData, + RawImagesMessageInfo, +} from '../../types/message/images'; +import type { + MediaMessageData, + RawMediaMessageInfo, +} from '../../types/message/media'; +import { createMediaMessageInfo } from '../message-utils'; import type { MessageSpec } from './message-spec'; -export const multimediaMessageSpec: MessageSpec = Object.freeze( - { - messageContent(data) { - const mediaIDs = data.media.map((media) => parseInt(media.id, 10)); - return JSON.stringify(mediaIDs); - }, +export const multimediaMessageSpec: MessageSpec< + MediaMessageData | ImagesMessageData, + RawMediaMessageInfo | RawImagesMessageInfo, +> = Object.freeze({ + messageContent(data) { + const mediaIDs = data.media.map((media) => parseInt(media.id, 10)); + return JSON.stringify(mediaIDs); + }, + + rawMessageInfoFromRow(row, params) { + const { localID, media } = params; + invariant(media, 'Media should be provided'); + return createMediaMessageInfo({ + threadID: row.threadID.toString(), + creatorID: row.creatorID.toString(), + media, + id: row.id.toString(), + localID, + time: row.time, + }); }, -); +}); diff --git a/lib/shared/messages/remove-members-message-spec.js b/lib/shared/messages/remove-members-message-spec.js index 27ded6847..f794720f8 100644 --- a/lib/shared/messages/remove-members-message-spec.js +++ b/lib/shared/messages/remove-members-message-spec.js @@ -1,12 +1,28 @@ // @flow -import type { RemoveMembersMessageData } from '../../types/message/remove-members'; +import { messageTypes } from '../../types/message-types'; +import type { + RawRemoveMembersMessageInfo, + RemoveMembersMessageData, +} from '../../types/message/remove-members'; import type { MessageSpec } from './message-spec'; -export const removeMembersMessageSpec: MessageSpec = Object.freeze( - { - messageContent(data) { - return JSON.stringify(data.removedUserIDs); - }, +export const removeMembersMessageSpec: MessageSpec< + RemoveMembersMessageData, + RawRemoveMembersMessageInfo, +> = Object.freeze({ + messageContent(data) { + return JSON.stringify(data.removedUserIDs); }, -); + + rawMessageInfoFromRow(row) { + return { + type: messageTypes.REMOVE_MEMBERS, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + removedUserIDs: JSON.parse(row.content), + }; + }, +}); diff --git a/lib/shared/messages/restore-entry-message-spec.js b/lib/shared/messages/restore-entry-message-spec.js index f9bb17b98..b81ac0bc7 100644 --- a/lib/shared/messages/restore-entry-message-spec.js +++ b/lib/shared/messages/restore-entry-message-spec.js @@ -1,16 +1,35 @@ // @flow -import type { RestoreEntryMessageData } from '../../types/message/restore-entry'; +import { messageTypes } from '../../types/message-types'; +import type { + RawRestoreEntryMessageInfo, + RestoreEntryMessageData, +} from '../../types/message/restore-entry'; import type { MessageSpec } from './message-spec'; -export const restoreEntryMessageSpec: MessageSpec = Object.freeze( - { - messageContent(data) { - return JSON.stringify({ - entryID: data.entryID, - date: data.date, - text: data.text, - }); - }, +export const restoreEntryMessageSpec: MessageSpec< + RestoreEntryMessageData, + RawRestoreEntryMessageInfo, +> = Object.freeze({ + messageContent(data) { + return JSON.stringify({ + entryID: data.entryID, + date: data.date, + text: data.text, + }); }, -); + + rawMessageInfoFromRow(row) { + const content = JSON.parse(row.content); + return { + type: messageTypes.RESTORE_ENTRY, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + entryID: content.entryID, + date: content.date, + text: content.text, + }; + }, +}); diff --git a/lib/shared/messages/sidebar-source-message-spec.js b/lib/shared/messages/sidebar-source-message-spec.js index 7b12cec2c..4ff08a9ec 100644 --- a/lib/shared/messages/sidebar-source-message-spec.js +++ b/lib/shared/messages/sidebar-source-message-spec.js @@ -1,14 +1,45 @@ // @flow -import type { SidebarSourceMessageData } from '../../types/message-types'; +import invariant from 'invariant'; + +import type { + RawSidebarSourceMessageInfo, + SidebarSourceMessageData, +} from '../../types/message-types'; +import { messageTypes } from '../../types/message-types'; import type { MessageSpec } from './message-spec'; -export const sidebarSourceMessageSpec: MessageSpec = Object.freeze( - { - messageContent(data) { - return JSON.stringify({ - sourceMessageID: data.sourceMessage.id, - }); - }, +export const sidebarSourceMessageSpec: MessageSpec< + SidebarSourceMessageData, + RawSidebarSourceMessageInfo, +> = Object.freeze({ + messageContent(data) { + const sourceMessageID = data.sourceMessage?.id; + invariant(sourceMessageID, 'Source message id should be set'); + return JSON.stringify({ + sourceMessageID, + }); + }, + + rawMessageInfoFromRow(row, params) { + const { derivedMessages } = params; + invariant(derivedMessages, 'Derived messages should be provided'); + + const content = JSON.parse(row.content); + const sourceMessage = derivedMessages.get(content.sourceMessageID); + if (!sourceMessage) { + console.warn( + `Message with id ${row.id} has a derived message ` + + `${content.sourceMessageID} which is not present in the database`, + ); + } + return { + type: messageTypes.SIDEBAR_SOURCE, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + sourceMessage, + }; }, -); +}); diff --git a/lib/shared/messages/text-message-spec.js b/lib/shared/messages/text-message-spec.js index 685d28f1a..0c32e57c3 100644 --- a/lib/shared/messages/text-message-spec.js +++ b/lib/shared/messages/text-message-spec.js @@ -1,10 +1,32 @@ // @flow -import type { TextMessageData } from '../../types/message/text'; +import { messageTypes } from '../../types/message-types'; +import type { + RawTextMessageInfo, + TextMessageData, +} from '../../types/message/text'; import type { MessageSpec } from './message-spec'; -export const textMessageSpec: MessageSpec = Object.freeze({ +export const textMessageSpec: MessageSpec< + TextMessageData, + RawTextMessageInfo, +> = Object.freeze({ messageContent(data) { return data.text; }, + + rawMessageInfoFromRow(row, params) { + const rawTextMessageInfo: RawTextMessageInfo = { + type: messageTypes.TEXT, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + text: row.content, + }; + if (params.localID) { + rawTextMessageInfo.localID = params.localID; + } + return rawTextMessageInfo; + }, }); diff --git a/lib/shared/messages/unsupported-message-spec.js b/lib/shared/messages/unsupported-message-spec.js index 0a004dbda..82f1dcf9b 100644 --- a/lib/shared/messages/unsupported-message-spec.js +++ b/lib/shared/messages/unsupported-message-spec.js @@ -1,5 +1,9 @@ // @flow +import type { RawUnsupportedMessageInfo } from '../../types/message/unsupported'; import type { MessageSpec } from './message-spec'; -export const unsupportedMessageSpec: MessageSpec = Object.freeze({}); +export const unsupportedMessageSpec: MessageSpec< + null, + RawUnsupportedMessageInfo, +> = Object.freeze({}); diff --git a/lib/shared/messages/update-relationship-message-spec.js b/lib/shared/messages/update-relationship-message-spec.js index 3bcc9d745..f6afec8db 100644 --- a/lib/shared/messages/update-relationship-message-spec.js +++ b/lib/shared/messages/update-relationship-message-spec.js @@ -1,15 +1,33 @@ // @flow -import type { UpdateRelationshipMessageData } from '../../types/message/update-relationship'; +import { messageTypes } from '../../types/message-types'; +import type { + RawUpdateRelationshipMessageInfo, + UpdateRelationshipMessageData, +} from '../../types/message/update-relationship'; import type { MessageSpec } from './message-spec'; -export const updateRelationshipMessageSpec: MessageSpec = Object.freeze( - { - messageContent(data) { - return JSON.stringify({ - operation: data.operation, - targetID: data.targetID, - }); - }, +export const updateRelationshipMessageSpec: MessageSpec< + UpdateRelationshipMessageData, + RawUpdateRelationshipMessageInfo, +> = Object.freeze({ + messageContent(data) { + return JSON.stringify({ + operation: data.operation, + targetID: data.targetID, + }); }, -); + + rawMessageInfoFromRow(row) { + const content = JSON.parse(row.content); + return { + type: messageTypes.UPDATE_RELATIONSHIP, + id: row.id.toString(), + threadID: row.threadID.toString(), + time: row.time, + creatorID: row.creatorID.toString(), + targetID: content.targetID, + operation: content.operation, + }; + }, +}); diff --git a/lib/types/message-types.js b/lib/types/message-types.js index 1df1f025d..442300c83 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,447 +1,447 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import type { FetchResultInfoInterface } from '../utils/fetch-json'; import type { AddMembersMessageData, AddMembersMessageInfo, RawAddMembersMessageInfo, } from './message/add-members'; import type { ChangeRoleMessageData, ChangeRoleMessageInfo, RawChangeRoleMessageInfo, } from './message/change-role'; import type { ChangeSettingsMessageData, ChangeSettingsMessageInfo, RawChangeSettingsMessageInfo, } from './message/change-settings'; import type { CreateEntryMessageData, CreateEntryMessageInfo, RawCreateEntryMessageInfo, } from './message/create-entry'; import type { CreateSidebarMessageData, CreateSidebarMessageInfo, RawCreateSidebarMessageInfo, } from './message/create-sidebar'; import type { CreateSubthreadMessageData, CreateSubthreadMessageInfo, RawCreateSubthreadMessageInfo, } from './message/create-subthread'; import type { CreateThreadMessageData, CreateThreadMessageInfo, RawCreateThreadMessageInfo, } from './message/create-thread'; import type { DeleteEntryMessageData, DeleteEntryMessageInfo, RawDeleteEntryMessageInfo, } from './message/delete-entry'; import type { EditEntryMessageData, EditEntryMessageInfo, RawEditEntryMessageInfo, } from './message/edit-entry'; import type { ImagesMessageData, ImagesMessageInfo, RawImagesMessageInfo, } from './message/images'; import type { JoinThreadMessageData, JoinThreadMessageInfo, RawJoinThreadMessageInfo, } from './message/join-thread'; import type { LeaveThreadMessageData, LeaveThreadMessageInfo, RawLeaveThreadMessageInfo, } from './message/leave-thread'; import type { MediaMessageData, MediaMessageInfo, RawMediaMessageInfo, } from './message/media'; import type { RawRemoveMembersMessageInfo, RemoveMembersMessageData, RemoveMembersMessageInfo, } from './message/remove-members'; import type { RawRestoreEntryMessageInfo, RestoreEntryMessageData, RestoreEntryMessageInfo, } from './message/restore-entry'; import type { RawTextMessageInfo, TextMessageData, TextMessageInfo, } from './message/text'; import type { RawUnsupportedMessageInfo, UnsupportedMessageInfo, } from './message/unsupported'; import type { RawUpdateRelationshipMessageInfo, UpdateRelationshipMessageData, UpdateRelationshipMessageInfo, } from './message/update-relationship'; import { type RelativeUserInfo, 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 SidebarSourceMessageData = {| +type: 17, +threadID: string, +creatorID: string, +time: number, - +sourceMessage: RawComposableMessageInfo | RawRobotextMessageInfo, + +sourceMessage?: RawComposableMessageInfo | RawRobotextMessageInfo, |}; 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 RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; -type RawRobotextMessageInfo = +export type RawRobotextMessageInfo = | RawCreateThreadMessageInfo | RawAddMembersMessageInfo | RawCreateSubthreadMessageInfo | RawChangeSettingsMessageInfo | RawRemoveMembersMessageInfo | RawChangeRoleMessageInfo | RawLeaveThreadMessageInfo | RawJoinThreadMessageInfo | RawCreateEntryMessageInfo | RawEditEntryMessageInfo | RawDeleteEntryMessageInfo | RawRestoreEntryMessageInfo | RawUpdateRelationshipMessageInfo | RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo; export type RawSidebarSourceMessageInfo = {| ...SidebarSourceMessageData, id: string, |}; export type RawMessageInfo = | RawComposableMessageInfo | RawRobotextMessageInfo | RawSidebarSourceMessageInfo; export type LocallyComposedMessageInfo = { localID: string, threadID: string, ... }; export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo; export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo; export type RobotextMessageInfo = | CreateThreadMessageInfo | AddMembersMessageInfo | CreateSubthreadMessageInfo | ChangeSettingsMessageInfo | RemoveMembersMessageInfo | ChangeRoleMessageInfo | LeaveThreadMessageInfo | JoinThreadMessageInfo | CreateEntryMessageInfo | EditEntryMessageInfo | DeleteEntryMessageInfo | RestoreEntryMessageInfo | UnsupportedMessageInfo | UpdateRelationshipMessageInfo | CreateSidebarMessageInfo; export type PreviewableMessageInfo = | RobotextMessageInfo | MultimediaMessageInfo; export type SidebarSourceMessageInfo = {| +type: 17, +id: string, +threadID: string, +creator: RelativeUserInfo, +time: number, +sourceMessage: ComposableMessageInfo | RobotextMessageInfo, |}; export type MessageInfo = | ComposableMessageInfo | RobotextMessageInfo | SidebarSourceMessageInfo; 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, |}; diff --git a/server/src/fetchers/message-fetchers.js b/server/src/fetchers/message-fetchers.js index 5b449e6b2..325d22466 100644 --- a/server/src/fetchers/message-fetchers.js +++ b/server/src/fetchers/message-fetchers.js @@ -1,793 +1,599 @@ // @flow import invariant from 'invariant'; -import { permissionLookup } from 'lib/permissions/thread-permissions'; import { sortMessageInfoList, shimUnsupportedRawMessageInfos, - createMediaMessageInfo, } from 'lib/shared/message-utils'; +import { messageSpecs } from 'lib/shared/messages/message-specs'; import { notifCollapseKeyForRawMessageInfo } from 'lib/shared/notif-utils'; import { type RawMessageInfo, messageTypes, type MessageType, assertMessageType, type ThreadSelectionCriteria, type MessageTruncationStatus, messageTruncationStatus, type FetchMessageInfosResult, } from 'lib/types/message-types'; -import type { RawTextMessageInfo } from 'lib/types/message/text'; import { threadPermissions } from 'lib/types/thread-types'; import { ServerError } from 'lib/utils/errors'; import { dbQuery, SQL, mergeOrConditions } from '../database/database'; import type { PushInfo } from '../push/send'; import type { Viewer } from '../session/viewer'; import { creationString, localIDFromCreationString } from '../utils/idempotent'; import { mediaFromRow } from './upload-fetchers'; export type CollapsableNotifInfo = {| collapseKey: ?string, existingMessageInfos: RawMessageInfo[], newMessageInfos: RawMessageInfo[], |}; export type FetchCollapsableNotifsResult = { [userID: string]: CollapsableNotifInfo[], }; // This function doesn't filter RawMessageInfos based on what messageTypes the // client supports, since each user can have multiple clients. The caller must // handle this filtering. async function fetchCollapsableNotifs( pushInfo: PushInfo, ): Promise { // First, we need to fetch any notifications that should be collapsed const usersToCollapseKeysToInfo = {}; const usersToCollapsableNotifInfo = {}; for (let userID in pushInfo) { usersToCollapseKeysToInfo[userID] = {}; usersToCollapsableNotifInfo[userID] = []; for (let rawMessageInfo of pushInfo[userID].messageInfos) { const collapseKey = notifCollapseKeyForRawMessageInfo(rawMessageInfo); if (!collapseKey) { const collapsableNotifInfo = { collapseKey, existingMessageInfos: [], newMessageInfos: [rawMessageInfo], }; usersToCollapsableNotifInfo[userID].push(collapsableNotifInfo); continue; } if (!usersToCollapseKeysToInfo[userID][collapseKey]) { usersToCollapseKeysToInfo[userID][collapseKey] = { collapseKey, existingMessageInfos: [], newMessageInfos: [], }; } usersToCollapseKeysToInfo[userID][collapseKey].newMessageInfos.push( rawMessageInfo, ); } } const sqlTuples = []; for (let userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; for (let collapseKey in collapseKeysToInfo) { sqlTuples.push( SQL`(n.user = ${userID} AND n.collapse_key = ${collapseKey})`, ); } } if (sqlTuples.length === 0) { return usersToCollapsableNotifInfo; } const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; const collapseQuery = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.user AS creatorID, stm.permissions AS subthread_permissions, n.user, n.collapse_key, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM notifications n LEFT JOIN messages m ON m.id = n.message LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = n.user LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = n.user WHERE n.rescinded = 0 AND JSON_EXTRACT(mm.permissions, ${visPermissionExtractString}) IS TRUE AND `; collapseQuery.append(mergeOrConditions(sqlTuples)); collapseQuery.append(SQL`ORDER BY m.time DESC`); const [collapseResult] = await dbQuery(collapseQuery); const rowsByUser = new Map(); for (const row of collapseResult) { const user = row.user.toString(); const currentRowsForUser = rowsByUser.get(user); if (currentRowsForUser) { currentRowsForUser.push(row); } else { rowsByUser.set(user, [row]); } } const derivedMessages = await fetchDerivedMessages(collapseResult); for (const userRows of rowsByUser.values()) { const messages = parseMessageSQLResult(userRows, derivedMessages); for (const message of messages) { const { rawMessageInfo, rows } = message; const [row] = rows; const info = usersToCollapseKeysToInfo[row.user][row.collapse_key]; info.existingMessageInfos.push(rawMessageInfo); } } for (let userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; for (let collapseKey in collapseKeysToInfo) { const info = collapseKeysToInfo[collapseKey]; usersToCollapsableNotifInfo[userID].push({ collapseKey: info.collapseKey, existingMessageInfos: sortMessageInfoList(info.existingMessageInfos), newMessageInfos: sortMessageInfoList(info.newMessageInfos), }); } } return usersToCollapsableNotifInfo; } type MessageSQLResult = $ReadOnlyArray<{| rawMessageInfo: RawMessageInfo, rows: $ReadOnlyArray, |}>; function parseMessageSQLResult( rows: $ReadOnlyArray, derivedMessages: $ReadOnlyMap, viewer?: Viewer, ): MessageSQLResult { const rowsByID = new Map(); for (let row of rows) { const id = row.id.toString(); const currentRowsForID = rowsByID.get(id); if (currentRowsForID) { currentRowsForID.push(row); } else { rowsByID.set(id, [row]); } } const messages = []; for (let messageRows of rowsByID.values()) { const rawMessageInfo = rawMessageInfoFromRows( messageRows, viewer, derivedMessages, ); if (rawMessageInfo) { messages.push({ rawMessageInfo, rows: messageRows }); } } return messages; } function assertSingleRow(rows: $ReadOnlyArray): Object { if (rows.length === 0) { throw new Error('expected single row, but none present!'); } else if (rows.length !== 1) { const messageIDs = rows.map((row) => row.id.toString()); console.warn( `expected single row, but there are multiple! ${messageIDs.join(', ')}`, ); } return rows[0]; } function mostRecentRowType(rows: $ReadOnlyArray): MessageType { if (rows.length === 0) { throw new Error('expected row, but none present!'); } return assertMessageType(rows[0].type); } function rawMessageInfoFromRows( rows: $ReadOnlyArray, viewer?: Viewer, derivedMessages: $ReadOnlyMap, ): ?RawMessageInfo { const type = mostRecentRowType(rows); - if (type === messageTypes.TEXT) { - const row = assertSingleRow(rows); - const rawTextMessageInfo: RawTextMessageInfo = { - type: messageTypes.TEXT, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - text: row.content, - }; - const localID = localIDFromCreationString(viewer, row.creation); - if (localID) { - rawTextMessageInfo.localID = localID; - } - return rawTextMessageInfo; - } else if (type === messageTypes.CREATE_THREAD) { - const row = assertSingleRow(rows); - return { - type: messageTypes.CREATE_THREAD, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - initialThreadState: JSON.parse(row.content), - }; - } else if (type === messageTypes.ADD_MEMBERS) { - const row = assertSingleRow(rows); - return { - type: messageTypes.ADD_MEMBERS, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - addedUserIDs: JSON.parse(row.content), - }; - } else if (type === messageTypes.CREATE_SUB_THREAD) { - const row = assertSingleRow(rows); - const subthreadPermissions = row.subthread_permissions; - if (!permissionLookup(subthreadPermissions, threadPermissions.KNOW_OF)) { - return null; - } - return { - type: messageTypes.CREATE_SUB_THREAD, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - childThreadID: row.content, - }; - } else if (type === messageTypes.CHANGE_SETTINGS) { - const row = assertSingleRow(rows); - const content = JSON.parse(row.content); - const field = Object.keys(content)[0]; - return { - type: messageTypes.CHANGE_SETTINGS, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - field, - value: content[field], - }; - } else if (type === messageTypes.REMOVE_MEMBERS) { - const row = assertSingleRow(rows); - return { - type: messageTypes.REMOVE_MEMBERS, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - removedUserIDs: JSON.parse(row.content), - }; - } else if (type === messageTypes.CHANGE_ROLE) { - const row = assertSingleRow(rows); - const content = JSON.parse(row.content); - return { - type: messageTypes.CHANGE_ROLE, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - userIDs: content.userIDs, - newRole: content.newRole, - }; - } else if (type === messageTypes.LEAVE_THREAD) { - const row = assertSingleRow(rows); - return { - type: messageTypes.LEAVE_THREAD, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - }; - } else if (type === messageTypes.JOIN_THREAD) { - const row = assertSingleRow(rows); - return { - type: messageTypes.JOIN_THREAD, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - }; - } else if (type === messageTypes.CREATE_ENTRY) { - const row = assertSingleRow(rows); - const content = JSON.parse(row.content); - return { - type: messageTypes.CREATE_ENTRY, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - entryID: content.entryID, - date: content.date, - text: content.text, - }; - } else if (type === messageTypes.EDIT_ENTRY) { - const row = assertSingleRow(rows); - const content = JSON.parse(row.content); - return { - type: messageTypes.EDIT_ENTRY, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - entryID: content.entryID, - date: content.date, - text: content.text, - }; - } else if (type === messageTypes.DELETE_ENTRY) { - const row = assertSingleRow(rows); - const content = JSON.parse(row.content); - return { - type: messageTypes.DELETE_ENTRY, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - entryID: content.entryID, - date: content.date, - text: content.text, - }; - } else if (type === messageTypes.RESTORE_ENTRY) { - const row = assertSingleRow(rows); - const content = JSON.parse(row.content); - return { - type: messageTypes.RESTORE_ENTRY, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - entryID: content.entryID, - date: content.date, - text: content.text, - }; - } else if (type === messageTypes.IMAGES || type === messageTypes.MULTIMEDIA) { + const messageSpec = messageSpecs[type]; + + if (type === messageTypes.IMAGES || type === messageTypes.MULTIMEDIA) { const media = rows.filter((row) => row.uploadID).map(mediaFromRow); const [row] = rows; - return createMediaMessageInfo({ - threadID: row.threadID.toString(), - creatorID: row.creatorID.toString(), + const localID = localIDFromCreationString(viewer, row.creation); + return messageSpec.rawMessageInfoFromRow(row, { media, - id: row.id.toString(), - localID: localIDFromCreationString(viewer, row.creation), - time: row.time, + derivedMessages, + localID, }); - } else if (type === messageTypes.UPDATE_RELATIONSHIP) { - const row = assertSingleRow(rows); - const content = JSON.parse(row.content); - return { - type: messageTypes.UPDATE_RELATIONSHIP, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - targetID: content.targetID, - operation: content.operation, - }; - } else if (type === messageTypes.SIDEBAR_SOURCE) { - const row = assertSingleRow(rows); - const content = JSON.parse(row.content); - const sourceMessage = derivedMessages.get(content.sourceMessageID); - if (!sourceMessage) { - console.warn( - `Message with id ${row.id} has a derived message ` + - `${content.sourceMessageID} which is not present in the database`, - ); - } - return { - type: messageTypes.SIDEBAR_SOURCE, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - sourceMessage, - }; - } else if (type === messageTypes.CREATE_SIDEBAR) { - const row = assertSingleRow(rows); - const { sourceMessageAuthorID, ...initialThreadState } = JSON.parse( - row.content, - ); - return { - type: messageTypes.CREATE_SIDEBAR, - id: row.id.toString(), - threadID: row.threadID.toString(), - time: row.time, - creatorID: row.creatorID.toString(), - sourceMessageAuthorID, - initialThreadState, - }; - } else { - invariant(false, `unrecognized messageType ${type}`); } + + const row = assertSingleRow(rows); + const localID = localIDFromCreationString(viewer, row.creation); + invariant( + messageSpec.rawMessageInfoFromRow, + `unrecognized messageType ${type}`, + ); + return messageSpec.rawMessageInfoFromRow(row, { derivedMessages, localID }); } const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; async function fetchMessageInfos( viewer: Viewer, criteria: ThreadSelectionCriteria, numberPerThread: number, ): Promise { const threadSelectionClause = threadSelectionCriteriaToSQLClause(criteria); const truncationStatuses = {}; const viewerID = viewer.id; const query = SQL` SELECT * FROM ( SELECT x.id, x.content, x.time, x.type, x.user AS creatorID, x.creation, x.subthread_permissions, x.uploadID, x.uploadType, x.uploadSecret, x.uploadExtra, @num := if( @thread = x.thread, if(@message = x.id, @num, @num + 1), 1 ) AS number, @message := x.id AS messageID, @thread := x.thread AS threadID FROM (SELECT @num := 0, @thread := '', @message := '') init JOIN ( SELECT m.id, m.thread, m.user, m.content, m.time, m.type, m.creation, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(threadSelectionClause); query.append(SQL` ORDER BY m.thread, m.time DESC ) x ) y WHERE y.number <= ${numberPerThread} `); const [result] = await dbQuery(query); const derivedMessages = await fetchDerivedMessages(result, viewer); const messages = parseMessageSQLResult(result, derivedMessages, viewer); const rawMessageInfos = []; const threadToMessageCount = new Map(); for (let message of messages) { const { rawMessageInfo } = message; rawMessageInfos.push(rawMessageInfo); const { threadID } = rawMessageInfo; const currentCountValue = threadToMessageCount.get(threadID); const currentCount = currentCountValue ? currentCountValue : 0; threadToMessageCount.set(threadID, currentCount + 1); } for (let [threadID, messageCount] of threadToMessageCount) { // If there are fewer messages returned than the max for a given thread, // then our result set includes all messages in the query range for that // thread truncationStatuses[threadID] = messageCount < numberPerThread ? messageTruncationStatus.EXHAUSTIVE : messageTruncationStatus.TRUNCATED; } for (let rawMessageInfo of rawMessageInfos) { if ( rawMessageInfo.type === messageTypes.CREATE_THREAD || rawMessageInfo.type === messageTypes.SIDEBAR_SOURCE ) { // If a CREATE_THREAD or SIDEBAR_SOURCE message for a given thread is in // the result set, then our result set includes all messages in the query // range for that thread truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } } for (let threadID in criteria.threadCursors) { const truncationStatus = truncationStatuses[threadID]; if (truncationStatus === null || truncationStatus === undefined) { // If nothing was returned for a thread that was explicitly queried for, // then our result set includes all messages in the query range for that // thread truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } else if (truncationStatus === messageTruncationStatus.TRUNCATED) { // If a cursor was specified for a given thread, then the result is // guaranteed to be contiguous with what the client has, and as such the // result should never be TRUNCATED truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } function threadSelectionCriteriaToSQLClause(criteria: ThreadSelectionCriteria) { const conditions = []; if (criteria.joinedThreads === true) { conditions.push(SQL`mm.role > 0`); } if (criteria.threadCursors) { for (let threadID in criteria.threadCursors) { const cursor = criteria.threadCursors[threadID]; if (cursor) { conditions.push(SQL`(m.thread = ${threadID} AND m.id < ${cursor})`); } else { conditions.push(SQL`m.thread = ${threadID}`); } } } if (conditions.length === 0) { throw new ServerError('internal_error'); } return mergeOrConditions(conditions); } function threadSelectionCriteriaToInitialTruncationStatuses( criteria: ThreadSelectionCriteria, defaultTruncationStatus: MessageTruncationStatus, ) { const truncationStatuses = {}; if (criteria.threadCursors) { for (let threadID in criteria.threadCursors) { truncationStatuses[threadID] = defaultTruncationStatus; } } return truncationStatuses; } async function fetchMessageInfosSince( viewer: Viewer, criteria: ThreadSelectionCriteria, currentAsOf: number, maxNumberPerThread: number, ): Promise { const threadSelectionClause = threadSelectionCriteriaToSQLClause(criteria); const truncationStatuses = threadSelectionCriteriaToInitialTruncationStatuses( criteria, messageTruncationStatus.UNCHANGED, ); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE m.time > ${currentAsOf} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(threadSelectionClause); query.append(SQL` ORDER BY m.thread, m.time DESC `); const [result] = await dbQuery(query); const derivedMessages = await fetchDerivedMessages(result, viewer); const messages = parseMessageSQLResult(result, derivedMessages, viewer); const rawMessageInfos = []; let currentThreadID = null; let numMessagesForCurrentThreadID = 0; for (let message of messages) { const { rawMessageInfo } = message; const { threadID } = rawMessageInfo; if (threadID !== currentThreadID) { currentThreadID = threadID; numMessagesForCurrentThreadID = 1; truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } else { numMessagesForCurrentThreadID++; } if (numMessagesForCurrentThreadID <= maxNumberPerThread) { if ( rawMessageInfo.type === messageTypes.CREATE_THREAD || rawMessageInfo.type === messageTypes.SIDEBAR_SOURCE ) { // If a CREATE_THREAD or SIDEBAR_SOURCE message is here, then we have // all messages truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } rawMessageInfos.push(rawMessageInfo); } else if (numMessagesForCurrentThreadID === maxNumberPerThread + 1) { truncationStatuses[threadID] = messageTruncationStatus.TRUNCATED; } } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } function getMessageFetchResultFromRedisMessages( viewer: Viewer, rawMessageInfos: $ReadOnlyArray, ): FetchMessageInfosResult { const truncationStatuses = {}; for (let rawMessageInfo of rawMessageInfos) { truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.UNCHANGED; } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } async function fetchMessageInfoForLocalID( viewer: Viewer, localID: ?string, ): Promise { if (!localID || !viewer.hasSessionInfo) { return null; } const creation = creationString(viewer, localID); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.creation = ${creation} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } const entryIDExtractString = '$.entryID'; async function fetchMessageInfoForEntryAction( viewer: Viewer, messageType: MessageType, entryID: string, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.thread = ${threadID} AND m.type = ${messageType} AND JSON_EXTRACT(m.content, ${entryIDExtractString}) = ${entryID} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } async function fetchMessageRowsByIDs(messageIDs: $ReadOnlyArray) { const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = m.user WHERE m.id IN (${messageIDs}) `; const [result] = await dbQuery(query); return result; } async function fetchDerivedMessages( rows: $ReadOnlyArray, viewer?: Viewer, ): Promise<$ReadOnlyMap> { const requiredIDs = new Set(); for (const row of rows) { if (row.type === messageTypes.SIDEBAR_SOURCE) { const content = JSON.parse(row.content); requiredIDs.add(content.sourceMessageID); } } const messagesByID = new Map(); if (requiredIDs.size === 0) { return messagesByID; } const result = await fetchMessageRowsByIDs([...requiredIDs]); const messages = parseMessageSQLResult(result, new Map(), viewer); for (const message of messages) { const { rawMessageInfo } = message; if (rawMessageInfo.id) { messagesByID.set(rawMessageInfo.id, rawMessageInfo); } } return messagesByID; } async function fetchMessageInfoByID( viewer?: Viewer, messageID: string, ): Promise { const result = await fetchMessageRowsByIDs([messageID]); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } export { fetchCollapsableNotifs, fetchMessageInfos, fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, fetchMessageInfoForLocalID, fetchMessageInfoForEntryAction, fetchMessageInfoByID, };