diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index ead6949e2..a99f3c8f6 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,1077 +1,1080 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy'; import { shimUploadURI, multimediaMessagePreview } from '../media/media-utils'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors'; import type { PlatformDetails } from '../types/device-types'; import type { Media, Image, Video } from '../types/media-types'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type PreviewableMessageInfo, type TextMessageInfo, type MediaMessageInfo, type ImagesMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageType, type MessageTruncationStatus, type RawImagesMessageInfo, type RawMediaMessageInfo, type MultimediaMessageData, type MediaMessageData, type ImagesMessageData, type MessageStore, messageTypes, messageTruncationStatus, } from '../types/message-types'; import { type ThreadInfo, threadTypes } from '../types/thread-types'; import type { RelativeUserInfo, UserInfos } from '../types/user-types'; import { prettyDate } from '../utils/date-utils'; import { codeBlockRegex } from './markdown'; import { stringForUser } from './user-utils'; import { hasMinCodeVersion } from './version-utils'; // Prefers localID function messageKey(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.localID) { return messageInfo.localID; } invariant(messageInfo.id, 'localID should exist if ID does not'); return messageInfo.id; } // Prefers serverID function messageID(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.id) { return messageInfo.id; } invariant(messageInfo.localID, 'localID should exist if ID does not'); return messageInfo.localID; } function robotextForUser(user: RelativeUserInfo): string { if (user.isViewer) { return 'you'; } else if (user.username) { return `<${encodeURI(user.username)}|u${user.id}>`; } else { return 'anonymous'; } } function robotextForUsers(users: RelativeUserInfo[]): string { if (users.length === 1) { return robotextForUser(users[0]); } else if (users.length === 2) { return `${robotextForUser(users[0])} and ${robotextForUser(users[1])}`; } else if (users.length === 3) { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${robotextForUser(users[2])}` ); } else { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${users.length - 2} others` ); } } function encodedThreadEntity(threadID: string, text: string): string { return `<${text}|t${threadID}>`; } function newThreadRobotext(messageInfo: RobotextMessageInfo, creator: string) { invariant( messageInfo.type === messageTypes.CREATE_THREAD || messageInfo.type === messageTypes.CREATE_SIDEBAR, `Expected CREATE_THREAD or CREATE_SIDEBAR message type, but received ${messageInfo.type}`, ); const threadTypeText = messageInfo.type === messageTypes.CREATE_SIDEBAR ? 'sidebar' : 'thread'; let text = `created ${encodedThreadEntity( messageInfo.threadID, `this ${threadTypeText}`, )}`; const parentThread = messageInfo.initialThreadState.parentThreadInfo; if (parentThread) { text += ' as a child of ' + `<${encodeURI(parentThread.uiName)}|t${parentThread.id}>`; } if (messageInfo.initialThreadState.name) { text += ` with the name "${encodeURI( messageInfo.initialThreadState.name, )}"`; } const users = messageInfo.initialThreadState.otherMembers; if (users.length !== 0) { const initialUsersString = robotextForUsers(users); text += ` and added ${initialUsersString}`; } return `${creator} ${text}`; } function robotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, ): string { const creator = robotextForUser(messageInfo.creator); if (messageInfo.type === messageTypes.CREATE_THREAD) { return newThreadRobotext(messageInfo, creator); } else if (messageInfo.type === messageTypes.ADD_MEMBERS) { const users = messageInfo.addedMembers; invariant(users.length !== 0, 'added who??'); const addedUsersString = robotextForUsers(users); return `${creator} added ${addedUsersString}`; } else if (messageInfo.type === messageTypes.CREATE_SUB_THREAD) { const childName = messageInfo.childThreadInfo.name; const childNoun = messageInfo.childThreadInfo.type === threadTypes.SIDEBAR ? 'sidebar' : 'child thread'; if (childName) { return ( `${creator} created a ${childNoun}` + ` named "<${encodeURI(childName)}|t${messageInfo.childThreadInfo.id}>"` ); } else { return ( `${creator} created a ` + `<${childNoun}|t${messageInfo.childThreadInfo.id}>` ); } } else if (messageInfo.type === messageTypes.CHANGE_SETTINGS) { let value; if (messageInfo.field === 'color') { value = `<#${messageInfo.value}|c${messageInfo.threadID}>`; } else { value = messageInfo.value; } return ( `${creator} updated ` + `${encodedThreadEntity(messageInfo.threadID, 'the thread')}'s ` + `${messageInfo.field} to "${value}"` ); } else if (messageInfo.type === messageTypes.REMOVE_MEMBERS) { const users = messageInfo.removedMembers; invariant(users.length !== 0, 'removed who??'); const removedUsersString = robotextForUsers(users); return `${creator} removed ${removedUsersString}`; } else if (messageInfo.type === messageTypes.CHANGE_ROLE) { const users = messageInfo.members; invariant(users.length !== 0, 'changed whose role??'); const usersString = robotextForUsers(users); const verb = threadInfo.roles[messageInfo.newRole].isDefault ? 'removed' : 'added'; const noun = users.length === 1 ? 'an admin' : 'admins'; return `${creator} ${verb} ${usersString} as ${noun}`; } else if (messageInfo.type === messageTypes.LEAVE_THREAD) { return ( `${creator} left ` + encodedThreadEntity(messageInfo.threadID, 'this thread') ); } else if (messageInfo.type === messageTypes.JOIN_THREAD) { return ( `${creator} joined ` + encodedThreadEntity(messageInfo.threadID, 'this thread') ); } else if (messageInfo.type === messageTypes.CREATE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} created an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.EDIT_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} updated the text of an event scheduled for ` + `${date}: "${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.DELETE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} deleted an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.RESTORE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} restored an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { const target = robotextForUser(messageInfo.target); if (messageInfo.operation === 'request_sent') { return `${creator} sent ${target} a friend request`; } else if (messageInfo.operation === 'request_accepted') { const targetPossessive = messageInfo.target.isViewer ? 'your' : `${target}'s`; return `${creator} accepted ${targetPossessive} friend request`; } invariant( false, `Invalid operation ${messageInfo.operation} ` + `of message with type ${messageInfo.type}`, ); } else if (messageInfo.type === messageTypes.CREATE_SIDEBAR) { return newThreadRobotext(messageInfo, creator); } else if (messageInfo.type === messageTypes.UNSUPPORTED) { return `${creator} ${messageInfo.robotext}`; } invariant(false, `we're not aware of messageType ${messageInfo.type}`); } function robotextToRawString(robotext: string): string { return decodeURI(robotext.replace(/<([^<>|]+)\|[^<>|]+>/g, '$1')); } function createMessageInfo( rawMessageInfo: RawMessageInfo, viewerID: ?string, userInfos: UserInfos, threadInfos: { [id: string]: ThreadInfo }, ): ?MessageInfo { const creatorInfo = userInfos[rawMessageInfo.creatorID]; if (!creatorInfo) { return null; } if (rawMessageInfo.type === messageTypes.TEXT) { const messageInfo: TextMessageInfo = { type: messageTypes.TEXT, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, text: rawMessageInfo.text, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.CREATE_THREAD) { const initialParentThreadID = rawMessageInfo.initialThreadState.parentThreadID; let parentThreadInfo = null; if (initialParentThreadID) { parentThreadInfo = threadInfos[initialParentThreadID]; } return { type: messageTypes.CREATE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, type: rawMessageInfo.initialThreadState.type, color: rawMessageInfo.initialThreadState.color, otherMembers: userIDsToRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), viewerID, userInfos, ), }, }; } else if (rawMessageInfo.type === messageTypes.ADD_MEMBERS) { const addedMembers = userIDsToRelativeUserInfos( rawMessageInfo.addedUserIDs, viewerID, userInfos, ); return { type: messageTypes.ADD_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, addedMembers, }; } else if (rawMessageInfo.type === messageTypes.CREATE_SUB_THREAD) { const childThreadInfo = threadInfos[rawMessageInfo.childThreadID]; if (!childThreadInfo) { return null; } return { type: messageTypes.CREATE_SUB_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, childThreadInfo, }; } else if (rawMessageInfo.type === messageTypes.CHANGE_SETTINGS) { return { type: messageTypes.CHANGE_SETTINGS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, field: rawMessageInfo.field, value: rawMessageInfo.value, }; } else if (rawMessageInfo.type === messageTypes.REMOVE_MEMBERS) { const removedMembers = userIDsToRelativeUserInfos( rawMessageInfo.removedUserIDs, viewerID, userInfos, ); return { type: messageTypes.REMOVE_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, removedMembers, }; } else if (rawMessageInfo.type === messageTypes.CHANGE_ROLE) { const members = userIDsToRelativeUserInfos( rawMessageInfo.userIDs, viewerID, userInfos, ); return { type: messageTypes.CHANGE_ROLE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, members, newRole: rawMessageInfo.newRole, }; } else if (rawMessageInfo.type === messageTypes.LEAVE_THREAD) { return { type: messageTypes.LEAVE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, }; } else if (rawMessageInfo.type === messageTypes.JOIN_THREAD) { return { type: messageTypes.JOIN_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, }; } else if (rawMessageInfo.type === messageTypes.CREATE_ENTRY) { return { type: messageTypes.CREATE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.EDIT_ENTRY) { return { type: messageTypes.EDIT_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.DELETE_ENTRY) { return { type: messageTypes.DELETE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.RESTORE_ENTRY) { return { type: messageTypes.RESTORE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.UNSUPPORTED) { return { type: messageTypes.UNSUPPORTED, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, robotext: rawMessageInfo.robotext, unsupportedMessageInfo: rawMessageInfo.unsupportedMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.IMAGES) { const messageInfo: ImagesMessageInfo = { type: messageTypes.IMAGES, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const messageInfo: MediaMessageInfo = { type: messageTypes.MULTIMEDIA, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { const target = userInfos[rawMessageInfo.targetID]; if (!target) { return null; } return { type: messageTypes.UPDATE_RELATIONSHIP, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, target: { id: target.id, username: target.username, isViewer: target.id === viewerID, }, time: rawMessageInfo.time, operation: rawMessageInfo.operation, }; } else if (rawMessageInfo.type === messageTypes.SIDEBAR_SOURCE) { const initialMessage = createMessageInfo( rawMessageInfo.initialMessage, viewerID, userInfos, threadInfos, ); invariant( initialMessage && initialMessage.type !== messageTypes.SIDEBAR_SOURCE, 'Sidebars can not be created from SIDEBAR SOURCE', ); return { type: messageTypes.SIDEBAR_SOURCE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, initialMessage, }; } else if (rawMessageInfo.type === messageTypes.CREATE_SIDEBAR) { const parentThreadInfo = threadInfos[rawMessageInfo.initialThreadState.parentThreadID]; return { type: messageTypes.CREATE_SIDEBAR, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, color: rawMessageInfo.initialThreadState.color, otherMembers: userIDsToRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), viewerID, userInfos, ), }, }; } invariant(false, `we're not aware of messageType ${rawMessageInfo.type}`); } function sortMessageInfoList( messageInfos: T[], ): T[] { return messageInfos.sort((a: T, b: T) => b.time - a.time); } function rawMessageInfoFromMessageData( messageData: MessageData, id: string, ): RawMessageInfo { if (messageData.type === messageTypes.TEXT) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CREATE_THREAD) { return { ...messageData, id }; } else if (messageData.type === messageTypes.ADD_MEMBERS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CREATE_SUB_THREAD) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CHANGE_SETTINGS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.REMOVE_MEMBERS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CHANGE_ROLE) { return { ...messageData, id }; } else if (messageData.type === messageTypes.LEAVE_THREAD) { return { type: messageTypes.LEAVE_THREAD, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, }; } else if (messageData.type === messageTypes.JOIN_THREAD) { return { type: messageTypes.JOIN_THREAD, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, }; } else if (messageData.type === messageTypes.CREATE_ENTRY) { return { type: messageTypes.CREATE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.EDIT_ENTRY) { return { type: messageTypes.EDIT_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.DELETE_ENTRY) { return { type: messageTypes.DELETE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.RESTORE_ENTRY) { return { type: messageTypes.RESTORE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.IMAGES) { return ({ ...messageData, id }: RawImagesMessageInfo); } else if (messageData.type === messageTypes.MULTIMEDIA) { return ({ ...messageData, id }: RawMediaMessageInfo); } else if (messageData.type === messageTypes.UPDATE_RELATIONSHIP) { return { ...messageData, id }; } else if (messageData.type === messageTypes.SIDEBAR_SOURCE) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CREATE_SIDEBAR) { return { ...messageData, id }; } else { invariant(false, `we're not aware of messageType ${messageData.type}`); } } function mostRecentMessageTimestamp( messageInfos: RawMessageInfo[], previousTimestamp: number, ): number { if (messageInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(messageInfos).time; } function messageTypeGeneratesNotifs(type: MessageType) { return ( type !== messageTypes.JOIN_THREAD && type !== messageTypes.LEAVE_THREAD && type !== messageTypes.ADD_MEMBERS && type !== messageTypes.REMOVE_MEMBERS && type !== messageTypes.SIDEBAR_SOURCE ); } function splitRobotext(robotext: string) { return robotext.split(/(<[^<>|]+\|[^<>|]+>)/g); } const robotextEntityRegex = /<([^<>|]+)\|([^<>|]+)>/; function parseRobotextEntity(robotextPart: string) { const entityParts = robotextPart.match(robotextEntityRegex); invariant(entityParts && entityParts[1], 'malformed robotext'); const rawText = decodeURI(entityParts[1]); const entityType = entityParts[2].charAt(0); const id = entityParts[2].substr(1); return { rawText, entityType, id }; } function usersInMessageInfos( messageInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (let messageInfo of messageInfos) { if (messageInfo.creatorID) { userIDs.add(messageInfo.creatorID); } else if (messageInfo.creator) { userIDs.add(messageInfo.creator.id); } } return [...userIDs]; } function combineTruncationStatuses( first: MessageTruncationStatus, second: ?MessageTruncationStatus, ): MessageTruncationStatus { if ( first === messageTruncationStatus.EXHAUSTIVE || second === messageTruncationStatus.EXHAUSTIVE ) { return messageTruncationStatus.EXHAUSTIVE; } else if ( first === messageTruncationStatus.UNCHANGED && second !== null && second !== undefined ) { return second; } else { return first; } } function shimUnsupportedRawMessageInfos( rawMessageInfos: $ReadOnlyArray, platformDetails: ?PlatformDetails, ): RawMessageInfo[] { if (platformDetails && platformDetails.platform === 'web') { return [...rawMessageInfos]; } return rawMessageInfos.map((rawMessageInfo) => { if (rawMessageInfo.type === messageTypes.IMAGES) { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); if (hasMinCodeVersion(platformDetails, 30)) { return shimmedRawMessageInfo; } const { id } = shimmedRawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: shimmedRawMessageInfo.threadID, creatorID: shimmedRawMessageInfo.creatorID, time: shimmedRawMessageInfo.time, robotext: multimediaMessagePreview(shimmedRawMessageInfo), unsupportedMessageInfo: shimmedRawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); // TODO figure out first native codeVersion supporting video playback if (hasMinCodeVersion(platformDetails, 62)) { return shimmedRawMessageInfo; } const { id } = shimmedRawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: shimmedRawMessageInfo.threadID, creatorID: shimmedRawMessageInfo.creatorID, time: shimmedRawMessageInfo.time, robotext: multimediaMessagePreview(shimmedRawMessageInfo), unsupportedMessageInfo: shimmedRawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { if (hasMinCodeVersion(platformDetails, 71)) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'performed a relationship action', unsupportedMessageInfo: rawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.SIDEBAR_SOURCE) { // TODO determine min code version - if (hasMinCodeVersion(platformDetails, 75)) { + if ( + hasMinCodeVersion(platformDetails, 75) && + rawMessageInfo.initialMessage + ) { 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/server/src/fetchers/message-fetchers.js b/server/src/fetchers/message-fetchers.js index 417ad9bf0..1a08995c2 100644 --- a/server/src/fetchers/message-fetchers.js +++ b/server/src/fetchers/message-fetchers.js @@ -1,780 +1,786 @@ // @flow import invariant from 'invariant'; import { permissionLookup } from 'lib/permissions/thread-permissions'; import { sortMessageInfoList, shimUnsupportedRawMessageInfos, createMediaMessageInfo, } from 'lib/shared/message-utils'; import { notifCollapseKeyForRawMessageInfo } from 'lib/shared/notif-utils'; import { type RawMessageInfo, messageTypes, type MessageType, assertMessageType, type ThreadSelectionCriteria, type MessageTruncationStatus, messageTruncationStatus, type FetchMessageInfosResult, type RawTextMessageInfo, } from 'lib/types/message-types'; 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 media = rows.filter((row) => row.uploadID).map(mediaFromRow); const [row] = rows; return createMediaMessageInfo({ threadID: row.threadID.toString(), creatorID: row.creatorID.toString(), media, id: row.id.toString(), localID: localIDFromCreationString(viewer, row.creation), time: row.time, }); } 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 initialMessage = derivedMessages.get(content.initialMessageID); + if (!initialMessage) { + console.warn( + `Message with id ${row.id} has a derived message ` + + `${content.initialMessageID} 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(), initialMessage, }; } else if (type === messageTypes.CREATE_SIDEBAR) { const row = assertSingleRow(rows); return { type: messageTypes.CREATE_SIDEBAR, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), initialThreadState: JSON.parse(row.content), }; } else { invariant(false, `unrecognized messageType ${type}`); } } 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, 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), '$') 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.initialMessageID); } } 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, };