diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index cffbe55f2..bde881b50 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,407 +1,412 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter'; import _flow from 'lodash/fp/flow'; import _map from 'lodash/fp/map'; import _orderBy from 'lodash/fp/orderBy'; import _memoize from 'lodash/memoize'; import PropTypes from 'prop-types'; import * as React from 'react'; import { useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { messageKey, robotextForMessageInfo, createMessageInfo, getMostRecentNonLocalMessageID, } from '../shared/message-utils'; import { threadIsTopLevel, threadInChatList } from '../shared/thread-utils'; import { type MessageInfo, type MessageStore, type ComposableMessageInfo, type RobotextMessageInfo, type LocalMessageInfo, messageInfoPropType, localMessageInfoPropType, messageTypes, isComposableMessageType, } from '../types/message-types'; import type { BaseAppState } from '../types/redux-types'; import { type ThreadInfo, threadInfoPropType, type RawThreadInfo, type SidebarInfo, maxReadSidebars, maxUnreadSidebars, } from '../types/thread-types'; import { userInfoPropType } from '../types/user-types'; import type { UserInfo } from '../types/user-types'; import { threeDays } from '../utils/date-utils'; import { threadInfoSelector, sidebarInfoSelector } from './thread-selectors'; type SidebarItem = | {| ...SidebarInfo, +type: 'sidebar', |} | {| +type: 'seeMore', +unread: boolean, +showingSidebarsInline: boolean, |}; export type ChatThreadItem = {| +type: 'chatThreadItem', +threadInfo: ThreadInfo, +mostRecentMessageInfo: ?MessageInfo, +mostRecentNonLocalMessage: ?string, +lastUpdatedTime: number, +lastUpdatedTimeIncludingSidebars: number, +sidebars: $ReadOnlyArray, +pendingPersonalThreadUserInfo?: UserInfo, |}; const chatThreadItemPropType = PropTypes.exact({ type: PropTypes.oneOf(['chatThreadItem']).isRequired, threadInfo: threadInfoPropType.isRequired, mostRecentMessageInfo: messageInfoPropType, mostRecentNonLocalMessage: PropTypes.string, lastUpdatedTime: PropTypes.number.isRequired, lastUpdatedTimeIncludingSidebars: PropTypes.number.isRequired, sidebars: PropTypes.arrayOf( PropTypes.oneOfType([ PropTypes.exact({ type: PropTypes.oneOf(['sidebar']).isRequired, threadInfo: threadInfoPropType.isRequired, lastUpdatedTime: PropTypes.number.isRequired, mostRecentNonLocalMessage: PropTypes.string, }), PropTypes.exact({ type: PropTypes.oneOf(['seeMore']).isRequired, unread: PropTypes.bool.isRequired, showingSidebarsInline: PropTypes.bool.isRequired, }), ]), ).isRequired, pendingPersonalThreadUserInfo: userInfoPropType, }); const messageInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: MessageInfo } = createObjectSelector( (state: BaseAppState<*>) => state.messageStore.messages, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, threadInfoSelector, createMessageInfo, ); function getMostRecentMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { [id: string]: MessageInfo }, ): ?MessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (let messageID of thread.messageIDs) { return messages[messageID]; } return null; } function getLastUpdatedTime( threadInfo: ThreadInfo, mostRecentMessageInfo: ?MessageInfo, ): number { return mostRecentMessageInfo ? mostRecentMessageInfo.time : threadInfo.creationTime; } function createChatThreadItem( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { [id: string]: MessageInfo }, sidebarInfos: ?$ReadOnlyArray, ): ChatThreadItem { const mostRecentMessageInfo = getMostRecentMessageInfo( threadInfo, messageStore, messages, ); const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( threadInfo, messageStore, ); const lastUpdatedTime = getLastUpdatedTime(threadInfo, mostRecentMessageInfo); const sidebars = sidebarInfos ?? []; const allSidebarItems = sidebars.map((sidebarInfo) => ({ type: 'sidebar', ...sidebarInfo, })); const lastUpdatedTimeIncludingSidebars = allSidebarItems.length > 0 ? Math.max(lastUpdatedTime, allSidebarItems[0].lastUpdatedTime) : lastUpdatedTime; const numUnreadSidebars = allSidebarItems.filter( (sidebar) => sidebar.threadInfo.currentUser.unread, ).length; let numReadSidebarsToShow = maxReadSidebars - numUnreadSidebars; const threeDaysAgo = Date.now() - threeDays; const sidebarItems = []; for (const sidebar of allSidebarItems) { if (sidebarItems.length >= maxUnreadSidebars) { break; } else if (sidebar.threadInfo.currentUser.unread) { sidebarItems.push(sidebar); } else if ( sidebar.lastUpdatedTime > threeDaysAgo && numReadSidebarsToShow > 0 ) { sidebarItems.push(sidebar); numReadSidebarsToShow--; } } if (sidebarItems.length < allSidebarItems.length) { sidebarItems.push({ type: 'seeMore', unread: numUnreadSidebars > maxUnreadSidebars, showingSidebarsInline: sidebarItems.length !== 0, }); } return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo, mostRecentNonLocalMessage, lastUpdatedTime, lastUpdatedTimeIncludingSidebars, sidebars: sidebarItems, }; } const chatListData: ( state: BaseAppState<*>, ) => ChatThreadItem[] = createSelector( threadInfoSelector, (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, sidebarInfoSelector, ( threadInfos: { [id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, sidebarInfos: { [id: string]: $ReadOnlyArray }, ): ChatThreadItem[] => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, threadIsTopLevel, ), ); function useFlattenedChatListData(): ChatThreadItem[] { const threadInfos = useSelector(threadInfoSelector); const messageInfos = useSelector(messageInfoSelector); const sidebarInfos = useSelector(sidebarInfoSelector); const messageStore = useSelector((state) => state.messageStore); return React.useMemo( () => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, threadInChatList, ), [messageInfos, messageStore, sidebarInfos, threadInfos], ); } function getChatThreadItems( threadInfos: { [id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, sidebarInfos: { [id: string]: $ReadOnlyArray }, filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean, ): ChatThreadItem[] { return _flow( _filter(filterFunction), _map((threadInfo: ThreadInfo): ChatThreadItem => createChatThreadItem( threadInfo, messageStore, messageInfos, sidebarInfos[threadInfo.id], ), ), _orderBy('lastUpdatedTimeIncludingSidebars')('desc'), )(threadInfos); } export type RobotextChatMessageInfoItem = {| itemType: 'message', messageInfo: RobotextMessageInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, robotext: string, |}; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem | {| itemType: 'message', messageInfo: ComposableMessageInfo, localMessageInfo: ?LocalMessageInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, |}; export type ChatMessageItem = {| itemType: 'loader' |} | ChatMessageInfoItem; const chatMessageItemPropType = PropTypes.oneOfType([ PropTypes.shape({ itemType: PropTypes.oneOf(['loader']).isRequired, }), PropTypes.shape({ itemType: PropTypes.oneOf(['message']).isRequired, messageInfo: messageInfoPropType.isRequired, localMessageInfo: localMessageInfoPropType, startsConversation: PropTypes.bool.isRequired, startsCluster: PropTypes.bool.isRequired, endsCluster: PropTypes.bool.isRequired, robotext: PropTypes.string, }), ]); const msInFiveMinutes = 5 * 60 * 1000; function createChatMessageItems( threadID: string, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, ): ChatMessageItem[] { const thread = messageStore.threads[threadID]; if (!thread) { return []; } const threadMessageInfos = thread.messageIDs .map((messageID: string) => messageInfos[messageID]) .filter(Boolean); const chatMessageItems = []; let lastMessageInfo = null; for (let i = threadMessageInfos.length - 1; i >= 0; i--) { const messageInfo = threadMessageInfos[i]; let startsConversation = true; let startsCluster = true; if ( lastMessageInfo && lastMessageInfo.time + msInFiveMinutes > messageInfo.time ) { startsConversation = false; if ( isComposableMessageType(lastMessageInfo.type) && isComposableMessageType(messageInfo.type) && lastMessageInfo.creator.id === messageInfo.creator.id ) { startsCluster = false; } } if (startsCluster && chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } - if (isComposableMessageType(messageInfo.type)) { + const originalMessageInfo = + messageInfo.type === messageTypes.SIDEBAR_SOURCE + ? messageInfo.initialMessage + : messageInfo; + if (isComposableMessageType(originalMessageInfo.type)) { // We use these invariants instead of just checking the messageInfo.type // directly in the conditional above so that isComposableMessageType can // be the source of truth invariant( - messageInfo.type === messageTypes.TEXT || - messageInfo.type === messageTypes.IMAGES || - messageInfo.type === messageTypes.MULTIMEDIA, + originalMessageInfo.type === messageTypes.TEXT || + originalMessageInfo.type === messageTypes.IMAGES || + originalMessageInfo.type === messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); - const localMessageInfo = messageStore.local[messageKey(messageInfo)]; + const localMessageInfo = + messageStore.local[messageKey(originalMessageInfo)]; chatMessageItems.push({ itemType: 'message', - messageInfo, + messageInfo: originalMessageInfo, localMessageInfo, startsConversation, startsCluster, endsCluster: false, }); } else { invariant( - messageInfo.type !== messageTypes.TEXT && - messageInfo.type !== messageTypes.IMAGES && - messageInfo.type !== messageTypes.MULTIMEDIA, + originalMessageInfo.type !== messageTypes.TEXT && + originalMessageInfo.type !== messageTypes.IMAGES && + originalMessageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const robotext = robotextForMessageInfo( - messageInfo, + originalMessageInfo, threadInfos[threadID], ); chatMessageItems.push({ itemType: 'message', - messageInfo, + messageInfo: originalMessageInfo, startsConversation, startsCluster, endsCluster: false, robotext, }); } lastMessageInfo = messageInfo; } if (chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } chatMessageItems.reverse(); if (thread.startReached) { return chatMessageItems; } return [...chatMessageItems, ({ itemType: 'loader' }: ChatMessageItem)]; } const baseMessageListData = (threadID: string) => createSelector( (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, threadInfoSelector, ( messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, ): ChatMessageItem[] => createChatMessageItems(threadID, messageStore, messageInfos, threadInfos), ); const messageListData: ( threadID: string, ) => (state: BaseAppState<*>) => ChatMessageItem[] = _memoize( baseMessageListData, ); export { messageInfoSelector, createChatThreadItem, chatThreadItemPropType, chatListData, chatMessageItemPropType, createChatMessageItems, messageListData, useFlattenedChatListData, }; diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index 77310a4f1..956bca5df 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,978 +1,1077 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy'; import { shimUploadURI, multimediaMessagePreview } from '../media/media-utils'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors'; import type { PlatformDetails } from '../types/device-types'; import type { Media, Image, Video } from '../types/media-types'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type PreviewableMessageInfo, type TextMessageInfo, type MediaMessageInfo, type ImagesMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageType, type MessageTruncationStatus, type RawImagesMessageInfo, type RawMediaMessageInfo, type MultimediaMessageData, type MediaMessageData, type ImagesMessageData, type MessageStore, messageTypes, messageTruncationStatus, } from '../types/message-types'; import { type ThreadInfo, threadTypes } from '../types/thread-types'; import type { RelativeUserInfo, UserInfos } from '../types/user-types'; import { prettyDate } from '../utils/date-utils'; import { codeBlockRegex } from './markdown'; import { stringForUser } from './user-utils'; import { hasMinCodeVersion } from './version-utils'; // Prefers localID function messageKey(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.localID) { return messageInfo.localID; } invariant(messageInfo.id, 'localID should exist if ID does not'); return messageInfo.id; } // Prefers serverID function messageID(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.id) { return messageInfo.id; } invariant(messageInfo.localID, 'localID should exist if ID does not'); return messageInfo.localID; } function robotextForUser(user: RelativeUserInfo): string { if (user.isViewer) { return 'you'; } else if (user.username) { return `<${encodeURI(user.username)}|u${user.id}>`; } else { return 'anonymous'; } } function robotextForUsers(users: RelativeUserInfo[]): string { if (users.length === 1) { return robotextForUser(users[0]); } else if (users.length === 2) { return `${robotextForUser(users[0])} and ${robotextForUser(users[1])}`; } else if (users.length === 3) { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${robotextForUser(users[2])}` ); } else { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${users.length - 2} others` ); } } function encodedThreadEntity(threadID: string, text: string): string { return `<${text}|t${threadID}>`; } +function newThreadRobotext(messageInfo: RobotextMessageInfo, creator: string) { + invariant( + messageInfo.type === messageTypes.CREATE_THREAD || + messageInfo.type === messageTypes.CREATE_SIDEBAR, + `Expected CREATE_THREAD or CREATE_SIDEBAR message type, but received ${messageInfo.type}`, + ); + const threadTypeText = + messageInfo.type === messageTypes.CREATE_SIDEBAR ? 'sidebar' : 'thread'; + let text = `created ${encodedThreadEntity( + messageInfo.threadID, + `this ${threadTypeText}`, + )}`; + const parentThread = messageInfo.initialThreadState.parentThreadInfo; + if (parentThread) { + text += + ' as a child of ' + + `<${encodeURI(parentThread.uiName)}|t${parentThread.id}>`; + } + if (messageInfo.initialThreadState.name) { + text += ` with the name "${encodeURI( + messageInfo.initialThreadState.name, + )}"`; + } + const users = messageInfo.initialThreadState.otherMembers; + if (users.length !== 0) { + const initialUsersString = robotextForUsers(users); + text += ` and added ${initialUsersString}`; + } + return `${creator} ${text}`; +} + function robotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, ): string { const creator = robotextForUser(messageInfo.creator); if (messageInfo.type === messageTypes.CREATE_THREAD) { - 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}`; + return newThreadRobotext(messageInfo, creator); } else if (messageInfo.type === messageTypes.ADD_MEMBERS) { const users = messageInfo.addedMembers; invariant(users.length !== 0, 'added who??'); const addedUsersString = robotextForUsers(users); return `${creator} added ${addedUsersString}`; } else if (messageInfo.type === messageTypes.CREATE_SUB_THREAD) { const childName = messageInfo.childThreadInfo.name; const childNoun = messageInfo.childThreadInfo.type === threadTypes.SIDEBAR ? 'sidebar' : 'child thread'; if (childName) { return ( `${creator} created a ${childNoun}` + ` named "<${encodeURI(childName)}|t${messageInfo.childThreadInfo.id}>"` ); } else { return ( `${creator} created a ` + `<${childNoun}|t${messageInfo.childThreadInfo.id}>` ); } } else if (messageInfo.type === messageTypes.CHANGE_SETTINGS) { let value; if (messageInfo.field === 'color') { value = `<#${messageInfo.value}|c${messageInfo.threadID}>`; } else { value = messageInfo.value; } return ( `${creator} updated ` + `${encodedThreadEntity(messageInfo.threadID, 'the thread')}'s ` + `${messageInfo.field} to "${value}"` ); } else if (messageInfo.type === messageTypes.REMOVE_MEMBERS) { const users = messageInfo.removedMembers; invariant(users.length !== 0, 'removed who??'); const removedUsersString = robotextForUsers(users); return `${creator} removed ${removedUsersString}`; } else if (messageInfo.type === messageTypes.CHANGE_ROLE) { const users = messageInfo.members; invariant(users.length !== 0, 'changed whose role??'); const usersString = robotextForUsers(users); const verb = threadInfo.roles[messageInfo.newRole].isDefault ? 'removed' : 'added'; const noun = users.length === 1 ? 'an admin' : 'admins'; return `${creator} ${verb} ${usersString} as ${noun}`; } else if (messageInfo.type === messageTypes.LEAVE_THREAD) { return ( `${creator} left ` + encodedThreadEntity(messageInfo.threadID, 'this thread') ); } else if (messageInfo.type === messageTypes.JOIN_THREAD) { return ( `${creator} joined ` + encodedThreadEntity(messageInfo.threadID, 'this thread') ); } else if (messageInfo.type === messageTypes.CREATE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} created an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.EDIT_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} updated the text of an event scheduled for ` + `${date}: "${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.DELETE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} deleted an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.RESTORE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} restored an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { const target = robotextForUser(messageInfo.target); if (messageInfo.operation === 'request_sent') { return `${creator} sent ${target} a friend request`; } else if (messageInfo.operation === 'request_accepted') { const targetPossessive = messageInfo.target.isViewer ? 'your' : `${target}'s`; return `${creator} accepted ${targetPossessive} friend request`; } invariant( false, `Invalid operation ${messageInfo.operation} ` + `of message with type ${messageInfo.type}`, ); + } else if (messageInfo.type === messageTypes.CREATE_SIDEBAR) { + return newThreadRobotext(messageInfo, creator); } else if (messageInfo.type === messageTypes.UNSUPPORTED) { return `${creator} ${messageInfo.robotext}`; } invariant(false, `we're not aware of messageType ${messageInfo.type}`); } function robotextToRawString(robotext: string): string { return decodeURI(robotext.replace(/<([^<>|]+)\|[^<>|]+>/g, '$1')); } function createMessageInfo( rawMessageInfo: RawMessageInfo, viewerID: ?string, userInfos: UserInfos, threadInfos: { [id: string]: ThreadInfo }, ): ?MessageInfo { const creatorInfo = userInfos[rawMessageInfo.creatorID]; if (!creatorInfo) { return null; } if (rawMessageInfo.type === messageTypes.TEXT) { const messageInfo: TextMessageInfo = { type: messageTypes.TEXT, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, text: rawMessageInfo.text, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.CREATE_THREAD) { const initialParentThreadID = rawMessageInfo.initialThreadState.parentThreadID; let parentThreadInfo = null; if (initialParentThreadID) { parentThreadInfo = threadInfos[initialParentThreadID]; } return { type: messageTypes.CREATE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, type: rawMessageInfo.initialThreadState.type, color: rawMessageInfo.initialThreadState.color, otherMembers: userIDsToRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), viewerID, userInfos, ), }, }; } else if (rawMessageInfo.type === messageTypes.ADD_MEMBERS) { const addedMembers = userIDsToRelativeUserInfos( rawMessageInfo.addedUserIDs, viewerID, userInfos, ); return { type: messageTypes.ADD_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, addedMembers, }; } else if (rawMessageInfo.type === messageTypes.CREATE_SUB_THREAD) { const childThreadInfo = threadInfos[rawMessageInfo.childThreadID]; if (!childThreadInfo) { return null; } return { type: messageTypes.CREATE_SUB_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, childThreadInfo, }; } else if (rawMessageInfo.type === messageTypes.CHANGE_SETTINGS) { return { type: messageTypes.CHANGE_SETTINGS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, field: rawMessageInfo.field, value: rawMessageInfo.value, }; } else if (rawMessageInfo.type === messageTypes.REMOVE_MEMBERS) { const removedMembers = userIDsToRelativeUserInfos( rawMessageInfo.removedUserIDs, viewerID, userInfos, ); return { type: messageTypes.REMOVE_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, removedMembers, }; } else if (rawMessageInfo.type === messageTypes.CHANGE_ROLE) { const members = userIDsToRelativeUserInfos( rawMessageInfo.userIDs, viewerID, userInfos, ); return { type: messageTypes.CHANGE_ROLE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, members, newRole: rawMessageInfo.newRole, }; } else if (rawMessageInfo.type === messageTypes.LEAVE_THREAD) { return { type: messageTypes.LEAVE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, }; } else if (rawMessageInfo.type === messageTypes.JOIN_THREAD) { return { type: messageTypes.JOIN_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, }; } else if (rawMessageInfo.type === messageTypes.CREATE_ENTRY) { return { type: messageTypes.CREATE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.EDIT_ENTRY) { return { type: messageTypes.EDIT_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.DELETE_ENTRY) { return { type: messageTypes.DELETE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.RESTORE_ENTRY) { return { type: messageTypes.RESTORE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.UNSUPPORTED) { return { type: messageTypes.UNSUPPORTED, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, robotext: rawMessageInfo.robotext, unsupportedMessageInfo: rawMessageInfo.unsupportedMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.IMAGES) { const messageInfo: ImagesMessageInfo = { type: messageTypes.IMAGES, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const messageInfo: MediaMessageInfo = { type: messageTypes.MULTIMEDIA, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { const target = userInfos[rawMessageInfo.targetID]; if (!target) { return null; } return { type: messageTypes.UPDATE_RELATIONSHIP, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, target: { id: target.id, username: target.username, isViewer: target.id === viewerID, }, time: rawMessageInfo.time, operation: rawMessageInfo.operation, }; + } else if (rawMessageInfo.type === messageTypes.SIDEBAR_SOURCE) { + const initialMessage = createMessageInfo( + rawMessageInfo.initialMessage, + viewerID, + userInfos, + threadInfos, + ); + invariant( + initialMessage && initialMessage.type === messageTypes.TEXT, + 'Sidebars can be created only from text messages', + ); + 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.REMOVE_MEMBERS && + type !== messageTypes.SIDEBAR_SOURCE ); } function splitRobotext(robotext: string) { return robotext.split(/(<[^<>|]+\|[^<>|]+>)/g); } const robotextEntityRegex = /<([^<>|]+)\|([^<>|]+)>/; function parseRobotextEntity(robotextPart: string) { const entityParts = robotextPart.match(robotextEntityRegex); invariant(entityParts && entityParts[1], 'malformed robotext'); const rawText = decodeURI(entityParts[1]); const entityType = entityParts[2].charAt(0); const id = entityParts[2].substr(1); return { rawText, entityType, id }; } function usersInMessageInfos( messageInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (let messageInfo of messageInfos) { if (messageInfo.creatorID) { userIDs.add(messageInfo.creatorID); } else if (messageInfo.creator) { userIDs.add(messageInfo.creator.id); } } return [...userIDs]; } function combineTruncationStatuses( first: MessageTruncationStatus, second: ?MessageTruncationStatus, ): MessageTruncationStatus { if ( first === messageTruncationStatus.EXHAUSTIVE || second === messageTruncationStatus.EXHAUSTIVE ) { return messageTruncationStatus.EXHAUSTIVE; } else if ( first === messageTruncationStatus.UNCHANGED && second !== null && second !== undefined ) { return second; } else { return first; } } function shimUnsupportedRawMessageInfos( rawMessageInfos: $ReadOnlyArray, platformDetails: ?PlatformDetails, ): RawMessageInfo[] { if (platformDetails && platformDetails.platform === 'web') { return [...rawMessageInfos]; } return rawMessageInfos.map((rawMessageInfo) => { if (rawMessageInfo.type === messageTypes.IMAGES) { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); if (hasMinCodeVersion(platformDetails, 30)) { return shimmedRawMessageInfo; } const { id } = shimmedRawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: shimmedRawMessageInfo.threadID, creatorID: shimmedRawMessageInfo.creatorID, time: shimmedRawMessageInfo.time, robotext: multimediaMessagePreview(shimmedRawMessageInfo), unsupportedMessageInfo: shimmedRawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); // TODO figure out first native codeVersion supporting video playback if (hasMinCodeVersion(platformDetails, 62)) { return shimmedRawMessageInfo; } const { id } = shimmedRawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: shimmedRawMessageInfo.threadID, creatorID: shimmedRawMessageInfo.creatorID, time: shimmedRawMessageInfo.time, robotext: multimediaMessagePreview(shimmedRawMessageInfo), unsupportedMessageInfo: shimmedRawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { if (hasMinCodeVersion(platformDetails, 71)) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'performed a relationship action', unsupportedMessageInfo: rawMessageInfo, }; + } else if (rawMessageInfo.type === messageTypes.SIDEBAR_SOURCE) { + // TODO determine min code version + if (hasMinCodeVersion(platformDetails, 75)) { + return rawMessageInfo; + } + const { id } = rawMessageInfo; + invariant(id !== null && id !== undefined, 'id should be set on server'); + return { + type: messageTypes.UNSUPPORTED, + id, + threadID: rawMessageInfo.threadID, + creatorID: rawMessageInfo.creatorID, + time: rawMessageInfo.time, + robotext: 'first message in sidebar', + unsupportedMessageInfo: rawMessageInfo, + }; + } else if (rawMessageInfo.type === messageTypes.CREATE_SIDEBAR) { + // TODO determine min code version + if (hasMinCodeVersion(platformDetails, 75)) { + return rawMessageInfo; + } + const { id } = rawMessageInfo; + invariant(id !== null && id !== undefined, 'id should be set on server'); + return { + type: messageTypes.UNSUPPORTED, + id, + threadID: rawMessageInfo.threadID, + creatorID: rawMessageInfo.creatorID, + time: rawMessageInfo.time, + robotext: 'created a sidebar', + unsupportedMessageInfo: rawMessageInfo, + }; } return rawMessageInfo; }); } function shimMediaMessageInfo( rawMessageInfo: RawMultimediaMessageInfo, platformDetails: ?PlatformDetails, ): RawMultimediaMessageInfo { if (rawMessageInfo.type === messageTypes.IMAGES) { let uriChanged = false; const newMedia: Image[] = []; for (let singleMedia of rawMessageInfo.media) { const shimmedURI = shimUploadURI(singleMedia.uri, platformDetails); if (shimmedURI === singleMedia.uri) { newMedia.push(singleMedia); } else { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Image)); uriChanged = true; } } if (!uriChanged) { return rawMessageInfo; } return ({ ...rawMessageInfo, media: newMedia, }: RawImagesMessageInfo); } else { let uriChanged = false; const newMedia: Media[] = []; for (let singleMedia of rawMessageInfo.media) { const shimmedURI = shimUploadURI(singleMedia.uri, platformDetails); if (shimmedURI === singleMedia.uri) { newMedia.push(singleMedia); } else if (singleMedia.type === 'photo') { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Image)); uriChanged = true; } else { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Video)); uriChanged = true; } } if (!uriChanged) { return rawMessageInfo; } return ({ ...rawMessageInfo, media: newMedia, }: RawMediaMessageInfo); } } function messagePreviewText( messageInfo: PreviewableMessageInfo, threadInfo: ThreadInfo, ): string { if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const creator = stringForUser(messageInfo.creator); const preview = multimediaMessagePreview(messageInfo); return `${creator} ${preview}`; } return robotextToRawString(robotextForMessageInfo(messageInfo, threadInfo)); } type MediaMessageDataCreationInput = $ReadOnly<{ threadID: string, creatorID: string, media: $ReadOnlyArray, localID?: ?string, time?: ?number, ... }>; function createMediaMessageData( input: MediaMessageDataCreationInput, ): MultimediaMessageData { let allMediaArePhotos = true; const photoMedia = []; for (let singleMedia of input.media) { if (singleMedia.type === 'video') { allMediaArePhotos = false; break; } else { photoMedia.push(singleMedia); } } const { localID, threadID, creatorID } = input; const time = input.time ? input.time : Date.now(); let messageData; if (allMediaArePhotos) { messageData = ({ type: messageTypes.IMAGES, threadID, creatorID, time, media: photoMedia, }: ImagesMessageData); } else { messageData = ({ type: messageTypes.MULTIMEDIA, threadID, creatorID, time, media: input.media, }: MediaMessageData); } if (localID) { messageData.localID = localID; } return messageData; } type MediaMessageInfoCreationInput = $ReadOnly<{ ...$Exact, id?: ?string, }>; function createMediaMessageInfo( input: MediaMessageInfoCreationInput, ): RawMultimediaMessageInfo { const messageData = createMediaMessageData(input); // This conditional is for Flow let rawMessageInfo; if (messageData.type === messageTypes.IMAGES) { rawMessageInfo = ({ ...messageData, type: messageTypes.IMAGES, }: RawImagesMessageInfo); } else { rawMessageInfo = ({ ...messageData, type: messageTypes.MULTIMEDIA, }: RawMediaMessageInfo); } if (input.id) { rawMessageInfo.id = input.id; } return rawMessageInfo; } function stripLocalIDs( input: $ReadOnlyArray, ): RawMessageInfo[] { const output = []; for (let rawMessageInfo of input) { if ( rawMessageInfo.localID === null || rawMessageInfo.localID === undefined ) { output.push(rawMessageInfo); continue; } invariant( rawMessageInfo.id, 'serverID should be set if localID is being stripped', ); if (rawMessageInfo.type === messageTypes.TEXT) { const { localID, ...rest } = rawMessageInfo; output.push({ ...rest }); } else if (rawMessageInfo.type === messageTypes.IMAGES) { const { localID, ...rest } = rawMessageInfo; output.push(({ ...rest }: RawImagesMessageInfo)); } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const { localID, ...rest } = rawMessageInfo; output.push(({ ...rest }: RawMediaMessageInfo)); } else { invariant( false, `message ${rawMessageInfo.id} of type ${rawMessageInfo.type} ` + `unexpectedly has localID`, ); } } return output; } // Normally we call trim() to remove whitespace at the beginning and end of each // message. However, our Markdown parser supports a "codeBlock" format where the // user can indent each line to indicate a code block. If we match the // corresponding RegEx, we'll only trim whitespace off the end. function trimMessage(message: string) { message = message.replace(/^\n*/, ''); return codeBlockRegex.exec(message) ? message.trimEnd() : message.trim(); } function createMessageReply(message: string) { // add `>` to each line to include empty lines in the quote const quotedMessage = message.replace(/^/gm, '> '); return quotedMessage + '\n\n'; } function getMostRecentNonLocalMessageID( threadInfo: ThreadInfo, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadInfo.id]; return thread?.messageIDs.find((id) => !id.startsWith('local')); } export { messageKey, messageID, robotextForMessageInfo, robotextToRawString, createMessageInfo, sortMessageInfoList, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, messageTypeGeneratesNotifs, splitRobotext, parseRobotextEntity, usersInMessageInfos, combineTruncationStatuses, shimUnsupportedRawMessageInfos, messagePreviewText, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageReply, getMostRecentNonLocalMessageID, }; diff --git a/lib/shared/notif-utils.js b/lib/shared/notif-utils.js index 3c43c35fd..3c98015db 100644 --- a/lib/shared/notif-utils.js +++ b/lib/shared/notif-utils.js @@ -1,534 +1,572 @@ // @flow import invariant from 'invariant'; import { contentStringForMediaArray } from '../media/media-utils'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type MessageType, messageTypes, } from '../types/message-types'; import type { ThreadInfo, ThreadType } from '../types/thread-types'; +import { threadTypes } from '../types/thread-types'; import type { RelativeUserInfo } from '../types/user-types'; import { prettyDate } from '../utils/date-utils'; import { values } from '../utils/objects'; import { pluralize } from '../utils/text-utils'; import { robotextForMessageInfo, robotextToRawString } from './message-utils'; import { threadIsGroupChat, threadNoun } from './thread-utils'; import { stringForUser } from './user-utils'; type NotifTexts = {| merged: string, body: string, title: string, prefix?: string, |}; function notifTextsForMessageInfo( messageInfos: MessageInfo[], threadInfo: ThreadInfo, ): NotifTexts { const fullNotifTexts = fullNotifTextsForMessageInfo(messageInfos, threadInfo); const result: NotifTexts = { merged: trimNotifText(fullNotifTexts.merged, 300), body: trimNotifText(fullNotifTexts.body, 300), title: trimNotifText(fullNotifTexts.title, 100), }; if (fullNotifTexts.prefix) { result.prefix = trimNotifText(fullNotifTexts.prefix, 50); } return result; } function trimNotifText(text: string, maxLength: number): string { if (text.length <= maxLength) { return text; } return text.substr(0, maxLength - 3) + '...'; } const notifTextForSubthreadCreation = ( creator: RelativeUserInfo, threadType: ThreadType, parentThreadInfo: ThreadInfo, childThreadName: ?string, childThreadUIName: string, ) => { const prefix = stringForUser(creator); let body = `created a new ${threadNoun(threadType)}`; if (parentThreadInfo.name) { body += ` in ${parentThreadInfo.name}`; } let merged = `${prefix} ${body}`; if (childThreadName) { merged += ` called "${childThreadName}"`; } return { merged, body, title: childThreadUIName, prefix, }; }; function notifThreadName(threadInfo: ThreadInfo): string { if (threadInfo.name) { return threadInfo.name; } else { return 'your thread'; } } function assertSingleMessageInfo( messageInfos: $ReadOnlyArray, ): MessageInfo { if (messageInfos.length === 0) { throw new Error('expected single MessageInfo, but none present!'); } else if (messageInfos.length !== 1) { const messageIDs = messageInfos.map((messageInfo) => messageInfo.id); console.log( 'expected single MessageInfo, but there are multiple! ' + messageIDs.join(', '), ); } return messageInfos[0]; } function mostRecentMessageInfoType( messageInfos: $ReadOnlyArray, ): MessageType { if (messageInfos.length === 0) { throw new Error('expected MessageInfo, but none present!'); } return messageInfos[0].type; } function fullNotifTextsForMessageInfo( messageInfos: MessageInfo[], threadInfo: ThreadInfo, ): NotifTexts { const mostRecentType = mostRecentMessageInfoType(messageInfos); if (mostRecentType === messageTypes.TEXT) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.TEXT, 'messageInfo should be messageTypes.TEXT!', ); if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { return { merged: `${threadInfo.uiName}: ${messageInfo.text}`, body: messageInfo.text, title: threadInfo.uiName, }; } else { const userString = stringForUser(messageInfo.creator); const threadName = notifThreadName(threadInfo); return { merged: `${userString} to ${threadName}: ${messageInfo.text}`, body: messageInfo.text, title: threadInfo.uiName, prefix: `${userString}:`, }; } } else if (mostRecentType === messageTypes.CREATE_THREAD) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_THREAD, 'messageInfo should be messageTypes.CREATE_THREAD!', ); const parentThreadInfo = messageInfo.initialThreadState.parentThreadInfo; if (parentThreadInfo) { return notifTextForSubthreadCreation( messageInfo.creator, messageInfo.initialThreadState.type, parentThreadInfo, messageInfo.initialThreadState.name, threadInfo.uiName, ); } const prefix = stringForUser(messageInfo.creator); const body = 'created a new thread'; let merged = `${prefix} ${body}`; if (messageInfo.initialThreadState.name) { merged += ` called "${messageInfo.initialThreadState.name}"`; } return { merged, body, title: threadInfo.uiName, prefix, }; } else if (mostRecentType === messageTypes.ADD_MEMBERS) { const addedMembersObject = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.ADD_MEMBERS, 'messageInfo should be messageTypes.ADD_MEMBERS!', ); for (let member of messageInfo.addedMembers) { addedMembersObject[member.id] = member; } } const addedMembers = values(addedMembersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.ADD_MEMBERS, 'messageInfo should be messageTypes.ADD_MEMBERS!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, addedMembers }; const robotext = strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} to ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; } else if (mostRecentType === messageTypes.CREATE_SUB_THREAD) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_SUB_THREAD, 'messageInfo should be messageTypes.CREATE_SUB_THREAD!', ); return notifTextForSubthreadCreation( messageInfo.creator, messageInfo.childThreadInfo.type, threadInfo, messageInfo.childThreadInfo.name, messageInfo.childThreadInfo.uiName, ); } else if (mostRecentType === messageTypes.REMOVE_MEMBERS) { const removedMembersObject = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.REMOVE_MEMBERS, 'messageInfo should be messageTypes.REMOVE_MEMBERS!', ); for (let member of messageInfo.removedMembers) { removedMembersObject[member.id] = member; } } const removedMembers = values(removedMembersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.REMOVE_MEMBERS, 'messageInfo should be messageTypes.REMOVE_MEMBERS!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, removedMembers }; const robotext = strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} from ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; } else if (mostRecentType === messageTypes.CHANGE_ROLE) { const membersObject = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.CHANGE_ROLE, 'messageInfo should be messageTypes.CHANGE_ROLE!', ); for (let member of messageInfo.members) { membersObject[member.id] = member; } } const members = values(membersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.CHANGE_ROLE, 'messageInfo should be messageTypes.CHANGE_ROLE!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, members }; const robotext = strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} from ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; } else if (mostRecentType === messageTypes.LEAVE_THREAD) { const leaverBeavers = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.LEAVE_THREAD, 'messageInfo should be messageTypes.LEAVE_THREAD!', ); leaverBeavers[messageInfo.creator.id] = messageInfo.creator; } const leavers = values(leaverBeavers); const leaversString = pluralize(leavers.map(stringForUser)); const body = `${leaversString} left`; const merged = `${body} ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body, }; } else if (mostRecentType === messageTypes.JOIN_THREAD) { const joinerArray = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.JOIN_THREAD, 'messageInfo should be messageTypes.JOIN_THREAD!', ); joinerArray[messageInfo.creator.id] = messageInfo.creator; } const joiners = values(joinerArray); const joinersString = pluralize(joiners.map(stringForUser)); const body = `${joinersString} joined`; const merged = `${body} ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body, }; } else if ( mostRecentType === messageTypes.CREATE_ENTRY || mostRecentType === messageTypes.EDIT_ENTRY ) { const hasCreateEntry = messageInfos.some( (messageInfo) => messageInfo.type === messageTypes.CREATE_ENTRY, ); const messageInfo = messageInfos[0]; if (!hasCreateEntry) { invariant( messageInfo.type === messageTypes.EDIT_ENTRY, 'messageInfo should be messageTypes.EDIT_ENTRY!', ); const body = `updated the text of an event in ` + `${notifThreadName(threadInfo)} scheduled for ` + `${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const prefix = stringForUser(messageInfo.creator); const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } invariant( messageInfo.type === messageTypes.CREATE_ENTRY || messageInfo.type === messageTypes.EDIT_ENTRY, 'messageInfo should be messageTypes.CREATE_ENTRY/EDIT_ENTRY!', ); const prefix = stringForUser(messageInfo.creator); const body = `created an event in ${notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } else if (mostRecentType === messageTypes.DELETE_ENTRY) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.DELETE_ENTRY, 'messageInfo should be messageTypes.DELETE_ENTRY!', ); const prefix = stringForUser(messageInfo.creator); const body = `deleted an event in ${notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } else if (mostRecentType === messageTypes.RESTORE_ENTRY) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.RESTORE_ENTRY, 'messageInfo should be messageTypes.RESTORE_ENTRY!', ); const prefix = stringForUser(messageInfo.creator); const body = `restored an event in ${notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } else if (mostRecentType === messageTypes.CHANGE_SETTINGS) { const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.CHANGE_SETTINGS, 'messageInfo should be messageTypes.CHANGE_SETTINGS!', ); const body = strippedRobotextForMessageInfo( mostRecentMessageInfo, threadInfo, ); return { merged: body, title: threadInfo.uiName, body, }; } else if ( mostRecentType === messageTypes.IMAGES || mostRecentType === messageTypes.MULTIMEDIA ) { const media = []; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, 'messageInfo should be multimedia type!', ); for (let singleMedia of messageInfo.media) { media.push(singleMedia); } } const contentString = contentStringForMediaArray(media); const userString = stringForUser(messageInfos[0].creator); let body, merged; if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { body = `sent you ${contentString}`; merged = body; } else { body = `sent ${contentString}`; const threadName = notifThreadName(threadInfo); merged = `${body} to ${threadName}`; } merged = `${userString} ${merged}`; return { merged, body, title: threadInfo.uiName, prefix: userString, }; } else if (mostRecentType === messageTypes.UPDATE_RELATIONSHIP) { const messageInfo = assertSingleMessageInfo(messageInfos); const prefix = stringForUser(messageInfo.creator); const title = threadInfo.uiName; const body = messageInfo.operation === 'request_sent' ? 'sent you a friend request' : 'accepted your friend request'; const merged = `${prefix} ${body}`; return { merged, body, title, prefix, }; + } else if (mostRecentType === messageTypes.SIDEBAR_SOURCE) { + const messageInfo = assertSingleMessageInfo(messageInfos); + invariant( + messageInfo.type === messageTypes.SIDEBAR_SOURCE, + 'messageInfo should be messageTypes.SIDEBAR_SOURCE!', + ); + const textMessageInfo = messageInfo.initialMessage; + if (!textMessageInfo.name && !threadIsGroupChat(textMessageInfo)) { + return { + merged: `${threadInfo.uiName}: ${textMessageInfo.text}`, + body: textMessageInfo.text, + title: threadInfo.uiName, + }; + } else { + const userString = stringForUser(textMessageInfo.creator); + const threadName = notifThreadName(threadInfo); + return { + merged: `${userString} to ${threadName}: ${textMessageInfo.text}`, + body: textMessageInfo.text, + title: threadInfo.uiName, + prefix: `${userString}:`, + }; + } + } else if (mostRecentType === messageTypes.CREATE_SIDEBAR) { + const messageInfo = assertSingleMessageInfo(messageInfos); + invariant( + messageInfo.type === messageTypes.CREATE_SIDEBAR, + 'messageInfo should be messageTypes.CREATE_SIDEBAR!', + ); + const parentThreadInfo = messageInfo.initialThreadState.parentThreadInfo; + return notifTextForSubthreadCreation( + messageInfo.creator, + threadTypes.SIDEBAR, + parentThreadInfo, + messageInfo.initialThreadState.name, + threadInfo.uiName, + ); } else { invariant(false, `we're not aware of messageType ${mostRecentType}`); } } function strippedRobotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, ): string { const robotext = robotextForMessageInfo(messageInfo, threadInfo); const threadEntityRegex = new RegExp(`<[^<>\\|]+\\|t${threadInfo.id}>`); const threadMadeExplicit = robotext.replace( threadEntityRegex, notifThreadName(threadInfo), ); return robotextToRawString(threadMadeExplicit); } const joinResult = (...keys: (string | number)[]) => keys.join('|'); function notifCollapseKeyForRawMessageInfo( rawMessageInfo: RawMessageInfo, ): ?string { if ( rawMessageInfo.type === messageTypes.ADD_MEMBERS || rawMessageInfo.type === messageTypes.REMOVE_MEMBERS ) { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { // We use the legacy constant here to collapse both types into one return joinResult( messageTypes.IMAGES, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); } else if (rawMessageInfo.type === messageTypes.CHANGE_SETTINGS) { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.field, ); } else if (rawMessageInfo.type === messageTypes.CHANGE_ROLE) { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.newRole, ); } else if ( rawMessageInfo.type === messageTypes.JOIN_THREAD || rawMessageInfo.type === messageTypes.LEAVE_THREAD ) { return joinResult(rawMessageInfo.type, rawMessageInfo.threadID); } else if ( rawMessageInfo.type === messageTypes.CREATE_ENTRY || rawMessageInfo.type === messageTypes.EDIT_ENTRY ) { return joinResult(rawMessageInfo.creatorID, rawMessageInfo.entryID); } else { return null; } } type Unmerged = $ReadOnly<{ body: string, title: string, prefix?: string, ... }>; type Merged = {| body: string, title: string, |}; function mergePrefixIntoBody(unmerged: Unmerged): Merged { const { body, title, prefix } = unmerged; const merged = prefix ? `${prefix} ${body}` : body; return { body: merged, title }; } export { notifTextsForMessageInfo, notifCollapseKeyForRawMessageInfo, mergePrefixIntoBody, }; diff --git a/lib/types/message-types.js b/lib/types/message-types.js index 8175d80d3..c2d031827 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,824 +1,907 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import type { FetchResultInfoInterface } from '../utils/fetch-json'; import { type Media, type Image, mediaPropType } from './media-types'; import { type ThreadInfo, threadInfoPropType, type ThreadType, threadTypePropType, } from './thread-types'; import { type RelativeUserInfo, relativeUserInfoPropType, type UserInfos, } from './user-types'; export const messageTypes = Object.freeze({ TEXT: 0, CREATE_THREAD: 1, ADD_MEMBERS: 2, CREATE_SUB_THREAD: 3, CHANGE_SETTINGS: 4, REMOVE_MEMBERS: 5, CHANGE_ROLE: 6, LEAVE_THREAD: 7, JOIN_THREAD: 8, CREATE_ENTRY: 9, EDIT_ENTRY: 10, DELETE_ENTRY: 11, RESTORE_ENTRY: 12, // When the server has a message to deliver that the client can't properly // render because the client is too old, the server will send this message // type instead. Consequently, there is no MessageData for UNSUPPORTED - just // a RawMessageInfo and a MessageInfo. Note that native/persist.js handles // converting these MessageInfos when the client is upgraded. UNSUPPORTED: 13, IMAGES: 14, MULTIMEDIA: 15, UPDATE_RELATIONSHIP: 16, + SIDEBAR_SOURCE: 17, + CREATE_SIDEBAR: 18, }); export type MessageType = $Values; export function assertMessageType(ourMessageType: number): MessageType { invariant( ourMessageType === 0 || ourMessageType === 1 || ourMessageType === 2 || ourMessageType === 3 || ourMessageType === 4 || ourMessageType === 5 || ourMessageType === 6 || ourMessageType === 7 || ourMessageType === 8 || ourMessageType === 9 || ourMessageType === 10 || ourMessageType === 11 || ourMessageType === 12 || ourMessageType === 13 || ourMessageType === 14 || ourMessageType === 15 || - ourMessageType === 16, + ourMessageType === 16 || + ourMessageType === 17 || + ourMessageType === 18, 'number is not MessageType enum', ); return ourMessageType; } const composableMessageTypes = new Set([ messageTypes.TEXT, messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isComposableMessageType(ourMessageType: MessageType): boolean { return composableMessageTypes.has(ourMessageType); } export function assertComposableMessageType( ourMessageType: MessageType, ): MessageType { invariant( isComposableMessageType(ourMessageType), 'MessageType is not composed', ); return ourMessageType; } export function messageDataLocalID(messageData: MessageData) { if ( messageData.type !== messageTypes.TEXT && messageData.type !== messageTypes.IMAGES && messageData.type !== messageTypes.MULTIMEDIA ) { return null; } return messageData.localID; } const mediaMessageTypes = new Set([ messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isMediaMessageType(ourMessageType: MessageType): boolean { return mediaMessageTypes.has(ourMessageType); } export function assetMediaMessageType( ourMessageType: MessageType, ): MessageType { invariant(isMediaMessageType(ourMessageType), 'MessageType is not media'); return ourMessageType; } // *MessageData = passed to createMessages function to insert into database // Raw*MessageInfo = used by server, and contained in client's local store // *MessageInfo = used by client in UI code export type TextMessageData = {| type: 0, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, text: string, |}; type CreateThreadMessageData = {| type: 1, threadID: string, creatorID: string, time: number, initialThreadState: {| type: ThreadType, name: ?string, parentThreadID: ?string, color: string, memberIDs: string[], |}, |}; type AddMembersMessageData = {| type: 2, threadID: string, creatorID: string, time: number, addedUserIDs: string[], |}; type CreateSubthreadMessageData = {| type: 3, threadID: string, creatorID: string, time: number, childThreadID: string, |}; type ChangeSettingsMessageData = {| type: 4, threadID: string, creatorID: string, time: number, field: string, value: string | number, |}; type RemoveMembersMessageData = {| type: 5, threadID: string, creatorID: string, time: number, removedUserIDs: string[], |}; type ChangeRoleMessageData = {| type: 6, threadID: string, creatorID: string, time: number, userIDs: string[], newRole: string, |}; type LeaveThreadMessageData = {| type: 7, threadID: string, creatorID: string, time: number, |}; type JoinThreadMessageData = {| type: 8, threadID: string, creatorID: string, time: number, |}; type CreateEntryMessageData = {| type: 9, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type EditEntryMessageData = {| type: 10, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type DeleteEntryMessageData = {| type: 11, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type RestoreEntryMessageData = {| type: 12, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; export type ImagesMessageData = {| type: 14, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, media: $ReadOnlyArray, |}; export type MediaMessageData = {| type: 15, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, media: $ReadOnlyArray, |}; export type UpdateRelationshipMessageData = {| +type: 16, +threadID: string, +creatorID: string, +targetID: string, +time: number, +operation: 'request_sent' | 'request_accepted', |}; +export type SidebarSourceMessageData = {| + +type: 17, + +threadID: string, + +creatorID: string, + +time: number, + +initialMessage: RawTextMessageInfo, +|}; +export type CreateSidebarMessageData = {| + +type: 18, + +threadID: string, + +creatorID: string, + +time: number, + +initialThreadState: {| + +name: ?string, + +parentThreadID: string, + +color: string, + +memberIDs: string[], + |}, +|}; export type MessageData = | TextMessageData | CreateThreadMessageData | AddMembersMessageData | CreateSubthreadMessageData | ChangeSettingsMessageData | RemoveMembersMessageData | ChangeRoleMessageData | LeaveThreadMessageData | JoinThreadMessageData | CreateEntryMessageData | EditEntryMessageData | DeleteEntryMessageData | RestoreEntryMessageData | ImagesMessageData | MediaMessageData - | UpdateRelationshipMessageData; + | UpdateRelationshipMessageData + | SidebarSourceMessageData + | CreateSidebarMessageData; export type MultimediaMessageData = ImagesMessageData | MediaMessageData; export type RawTextMessageInfo = {| ...TextMessageData, id?: string, // null if local copy without ID yet |}; export type RawImagesMessageInfo = {| ...ImagesMessageData, id?: string, // null if local copy without ID yet |}; export type RawMediaMessageInfo = {| ...MediaMessageData, id?: string, // null if local copy without ID yet |}; export type RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; type RawRobotextMessageInfo = | {| ...CreateThreadMessageData, id: string, |} | {| ...AddMembersMessageData, id: string, |} | {| ...CreateSubthreadMessageData, id: string, |} | {| ...ChangeSettingsMessageData, id: string, |} | {| ...RemoveMembersMessageData, id: string, |} | {| ...ChangeRoleMessageData, id: string, |} | {| ...LeaveThreadMessageData, id: string, |} | {| ...JoinThreadMessageData, id: string, |} | {| ...CreateEntryMessageData, id: string, |} | {| ...EditEntryMessageData, id: string, |} | {| ...DeleteEntryMessageData, id: string, |} | {| ...RestoreEntryMessageData, id: string, |} | {| ...UpdateRelationshipMessageData, id: string, |} + | {| + ...CreateSidebarMessageData, + id: string, + |} | {| type: 13, id: string, threadID: string, creatorID: string, time: number, robotext: string, unsupportedMessageInfo: Object, |}; -export type RawMessageInfo = RawComposableMessageInfo | RawRobotextMessageInfo; +export type RawSidebarSourceMessageInfo = {| + ...SidebarSourceMessageData, + id: string, +|}; +export type RawMessageInfo = + | RawComposableMessageInfo + | RawRobotextMessageInfo + | RawSidebarSourceMessageInfo; export type LocallyComposedMessageInfo = { localID: string, threadID: string, ... }; export type TextMessageInfo = {| type: 0, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp text: string, |}; export type ImagesMessageInfo = {| type: 14, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp media: $ReadOnlyArray, |}; export type MediaMessageInfo = {| type: 15, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp media: $ReadOnlyArray, |}; export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo; export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo; export type RobotextMessageInfo = | {| type: 1, id: string, threadID: string, creator: RelativeUserInfo, time: number, initialThreadState: {| type: ThreadType, name: ?string, parentThreadInfo: ?ThreadInfo, color: string, otherMembers: RelativeUserInfo[], |}, |} | {| type: 2, id: string, threadID: string, creator: RelativeUserInfo, time: number, addedMembers: RelativeUserInfo[], |} | {| type: 3, id: string, threadID: string, creator: RelativeUserInfo, time: number, childThreadInfo: ThreadInfo, |} | {| type: 4, id: string, threadID: string, creator: RelativeUserInfo, time: number, field: string, value: string | number, |} | {| type: 5, id: string, threadID: string, creator: RelativeUserInfo, time: number, removedMembers: RelativeUserInfo[], |} | {| type: 6, id: string, threadID: string, creator: RelativeUserInfo, time: number, members: RelativeUserInfo[], newRole: string, |} | {| type: 7, id: string, threadID: string, creator: RelativeUserInfo, time: number, |} | {| type: 8, id: string, threadID: string, creator: RelativeUserInfo, time: number, |} | {| type: 9, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 10, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 11, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 12, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 13, id: string, threadID: string, creator: RelativeUserInfo, time: number, robotext: string, unsupportedMessageInfo: Object, |} | {| +type: 16, +id: string, +threadID: string, +creator: RelativeUserInfo, +target: RelativeUserInfo, +time: number, +operation: 'request_sent' | 'request_accepted', + |} + | {| + +type: 18, + +id: string, + +threadID: string, + +creator: RelativeUserInfo, + +time: number, + +initialThreadState: {| + +name: ?string, + +parentThreadInfo: ThreadInfo, + +color: string, + +otherMembers: RelativeUserInfo[], + |}, |}; export type PreviewableMessageInfo = | RobotextMessageInfo | MultimediaMessageInfo; +export type SidebarSourceMessageInfo = {| + +type: 17, + +id: string, + +threadID: string, + +creator: RelativeUserInfo, + +time: number, + +initialMessage: TextMessageInfo, +|}; -export type MessageInfo = ComposableMessageInfo | RobotextMessageInfo; +export type MessageInfo = + | ComposableMessageInfo + | RobotextMessageInfo + | SidebarSourceMessageInfo; + +export const textMessagePropType = PropTypes.shape({ + type: PropTypes.oneOf([messageTypes.TEXT]).isRequired, + id: PropTypes.string, + localID: PropTypes.string, + threadID: PropTypes.string.isRequired, + creator: relativeUserInfoPropType.isRequired, + time: PropTypes.number.isRequired, + text: PropTypes.string.isRequired, +}); export const messageInfoPropType = PropTypes.oneOfType([ - PropTypes.shape({ - type: PropTypes.oneOf([messageTypes.TEXT]).isRequired, - id: PropTypes.string, - localID: PropTypes.string, - threadID: PropTypes.string.isRequired, - creator: relativeUserInfoPropType.isRequired, - time: PropTypes.number.isRequired, - text: PropTypes.string.isRequired, - }), + textMessagePropType, PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, initialThreadState: PropTypes.shape({ type: threadTypePropType.isRequired, name: PropTypes.string, parentThreadInfo: threadInfoPropType, color: PropTypes.string.isRequired, otherMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.ADD_MEMBERS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, addedMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_SUB_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, childThreadInfo: threadInfoPropType.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CHANGE_SETTINGS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, field: PropTypes.string.isRequired, value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.REMOVE_MEMBERS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, removedMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CHANGE_ROLE]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, members: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, newRole: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.LEAVE_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.JOIN_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.EDIT_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.DELETE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.RESTORE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.UNSUPPORTED]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, robotext: PropTypes.string.isRequired, unsupportedMessageInfo: PropTypes.object.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.IMAGES]).isRequired, id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, media: PropTypes.arrayOf(mediaPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.MULTIMEDIA]).isRequired, id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, media: PropTypes.arrayOf(mediaPropType).isRequired, }), PropTypes.exact({ type: PropTypes.oneOf([messageTypes.UPDATE_RELATIONSHIP]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, target: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, operation: PropTypes.oneOf(['request_sent', 'request_accepted']), }), + PropTypes.exact({ + type: PropTypes.oneOf([messageTypes.SIDEBAR_SOURCE]).isRequired, + id: PropTypes.string.isRequired, + threadID: PropTypes.string.isRequired, + creator: relativeUserInfoPropType.isRequired, + time: PropTypes.number.isRequired, + initialMessage: textMessagePropType.isRequired, + }), + PropTypes.exact({ + type: PropTypes.oneOf([messageTypes.CREATE_SIDEBAR]).isRequired, + id: PropTypes.string.isRequired, + threadID: PropTypes.string.isRequired, + creator: relativeUserInfoPropType.isRequired, + time: PropTypes.number.isRequired, + initialThreadState: PropTypes.shape({ + name: PropTypes.string, + parentThreadInfo: threadInfoPropType.isRequired, + color: PropTypes.string.isRequired, + otherMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, + }).isRequired, + }), ]); export 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/native/chat/message-list-container.react.js b/native/chat/message-list-container.react.js index 3f8d07d71..3b614e31b 100644 --- a/native/chat/message-list-container.react.js +++ b/native/chat/message-list-container.react.js @@ -1,463 +1,467 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { type ChatMessageItem, messageListData as messageListDataSelector, } from 'lib/selectors/chat-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors'; import { messageID } from 'lib/shared/message-utils'; import { getPotentialMemberItems } from 'lib/shared/search-utils'; import { createPendingThread, getCurrentUser, getPendingThreadKey, pendingThreadType, threadHasAdminRole, threadIsPending, } from 'lib/shared/thread-utils'; import { messageTypes } from 'lib/types/message-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types'; import ContentLoading from '../components/content-loading.react'; import NodeHeightMeasurer from '../components/node-height-measurer.react'; import { type InputState, InputStateContext } from '../input/input-state'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../themes/colors'; import ChatInputBar from './chat-input-bar.react'; import { chatMessageItemKey } from './chat-list.react'; import type { ChatNavigationProp } from './chat.react'; import { composedMessageMaxWidthSelector } from './composed-message-width'; import { dummyNodeForTextMessageHeightMeasurement } from './inner-text-message.react'; import MessageListThreadSearch from './message-list-thread-search.react'; import { MessageListContext, useMessageListContext, } from './message-list-types'; import MessageList from './message-list.react'; import type { ChatMessageInfoItemWithHeight } from './message.react'; import { multimediaMessageContentSizes } from './multimedia-message.react'; import { dummyNodeForRobotextMessageHeightMeasurement } from './robotext-message.react'; export type ChatMessageItemWithHeight = | {| itemType: 'loader' |} | ChatMessageInfoItemWithHeight; type BaseProps = {| +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, |}; type Props = {| ...BaseProps, // Redux state +usernameInputText: string, +updateUsernameInput: (text: string) => void, +userInfoInputArray: $ReadOnlyArray, +updateTagInput: (items: $ReadOnlyArray) => void, +otherUserInfos: { [id: string]: AccountUserInfo }, +userSearchResults: $ReadOnlyArray, +threadInfo: ThreadInfo, +messageListData: $ReadOnlyArray, +composedMessageMaxWidth: number, +colors: Colors, +styles: typeof unboundStyles, // withInputState +inputState: ?InputState, // withOverlayContext +overlayContext: ?OverlayContextType, |}; type State = {| +listDataWithHeights: ?$ReadOnlyArray, |}; class MessageListContainer extends React.PureComponent { state: State = { listDataWithHeights: null, }; pendingListDataWithHeights: ?$ReadOnlyArray; get frozen() { const { overlayContext } = this.props; invariant( overlayContext, 'MessageListContainer should have OverlayContext', ); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidUpdate(prevProps: Props) { const oldListData = prevProps.messageListData; const newListData = this.props.messageListData; if (!newListData && oldListData) { this.setState({ listDataWithHeights: null }); } if (!this.frozen && this.pendingListDataWithHeights) { this.setState({ listDataWithHeights: this.pendingListDataWithHeights }); this.pendingListDataWithHeights = undefined; } } render() { const { threadInfo, styles } = this.props; const { listDataWithHeights } = this.state; const { searching } = this.props.route.params; let searchComponent = null; if (searching) { searchComponent = ( ); } const showMessageList = !searching || this.props.userInfoInputArray.length > 0; let threadContent = null; if (showMessageList) { let messageList; if (listDataWithHeights) { messageList = ( ); } else { messageList = ( ); } threadContent = ( {messageList} ); } return ( {searchComponent} {threadContent} ); } heightMeasurerID = (item: ChatMessageItem) => { return chatMessageItemKey(item); }; heightMeasurerKey = (item: ChatMessageItem) => { if (item.itemType !== 'message') { return null; } const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return messageInfo.text; } else if (item.robotext && typeof item.robotext === 'string') { return item.robotext; } return null; }; heightMeasurerDummy = (item: ChatMessageItem) => { invariant( item.itemType === 'message', 'NodeHeightMeasurer asked for dummy for non-message item', ); const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return dummyNodeForTextMessageHeightMeasurement(messageInfo.text); } else if (item.robotext && typeof item.robotext === 'string') { return dummyNodeForRobotextMessageHeightMeasurement(item.robotext); } invariant(false, 'NodeHeightMeasurer asked for dummy for non-text message'); }; heightMeasurerMergeItem = (item: ChatMessageItem, height: ?number) => { if (item.itemType !== 'message') { return item; } const { messageInfo } = item; + invariant( + messageInfo.type !== messageTypes.SIDEBAR_SOURCE, + 'Sidebar source messages should be replaced by initialMessage before being measured', + ); const { threadInfo } = this.props; if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const { inputState } = this.props; // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; const id = messageID(messageInfo); const pendingUploads = inputState && inputState.pendingUploads && inputState.pendingUploads[id]; const sizes = multimediaMessageContentSizes( messageInfo, this.props.composedMessageMaxWidth, ); return { itemType: 'message', messageShapeType: 'multimedia', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, pendingUploads, ...sizes, }; } invariant(height !== null && height !== undefined, 'height should be set'); if (messageInfo.type === messageTypes.TEXT) { // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; return { itemType: 'message', messageShapeType: 'text', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, contentHeight: height, }; } else { invariant( typeof item.robotext === 'string', "Flow can't handle our fancy types :(", ); return { itemType: 'message', messageShapeType: 'robotext', messageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, robotext: item.robotext, contentHeight: height, }; } }; allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { if (this.frozen) { this.pendingListDataWithHeights = listDataWithHeights; } else { this.setState({ listDataWithHeights }); } }; } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, threadContent: { flex: 1, }, }; export default React.memo(function ConnectedMessageListContainer( props: BaseProps, ) { const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const updateTagInput = React.useCallback( (input: $ReadOnlyArray) => setUserInfoInputArray(input), [], ); const updateUsernameInput = React.useCallback( (text: string) => setUsernameInputText(text), [], ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const userSearchResults = React.useMemo( () => getPotentialMemberItems( usernameInputText, otherUserInfos, userSearchIndex, userInfoInputArray.map((userInfo) => userInfo.id), ), [usernameInputText, otherUserInfos, userSearchIndex, userInfoInputArray], ); const threadInfos = useSelector(threadInfoSelector); const userInfos = useSelector((state) => state.userStore.userInfos); const threadInfoRef = React.useRef(props.route.params.threadInfo); const [originalThreadInfo, setOriginalThreadInfo] = React.useState( props.route.params.threadInfo, ); const { searching } = props.route.params; const inputState = React.useContext(InputStateContext); const hideSearch = React.useCallback(() => { setOriginalThreadInfo(threadInfoRef.current); props.navigation.setParams({ searching: false, }); }, [props.navigation]); React.useEffect(() => { if (!searching) { return; } inputState?.registerSendCallback(hideSearch); return () => inputState?.unregisterSendCallback(hideSearch); }, [hideSearch, inputState, searching]); const threadCandidates = React.useMemo(() => { const infos = new Map(); for (const threadID in threadInfos) { const info = threadInfos[threadID]; if (info.parentThreadID || threadHasAdminRole(info)) { continue; } const key = getPendingThreadKey(info.members.map((member) => member.id)); const indexedThread = infos.get(key); if (!indexedThread || info.creationTime < indexedThread.creationTime) { infos.set(key, info); } } return infos; }, [threadInfos]); const latestThreadInfo = React.useMemo((): ?ThreadInfo => { const threadInfoFromParams = originalThreadInfo; const threadInfoFromStore = threadInfos[threadInfoFromParams.id]; if (threadInfoFromStore) { return threadInfoFromStore; } else if (!viewerID || !threadIsPending(threadInfoFromParams.id)) { return undefined; } const pendingThreadMemberIDs = searching ? [...userInfoInputArray.map((user) => user.id), viewerID] : threadInfoFromParams.members.map((member) => member.id); const threadKey = getPendingThreadKey(pendingThreadMemberIDs); if (threadCandidates.get(threadKey)) { return threadCandidates.get(threadKey); } const updatedThread = searching ? createPendingThread( viewerID, pendingThreadType(userInfoInputArray.length), userInfoInputArray, ) : threadInfoFromParams; return { ...updatedThread, currentUser: getCurrentUser(updatedThread, viewerID, userInfos), }; }, [ originalThreadInfo, threadInfos, viewerID, searching, userInfoInputArray, threadCandidates, userInfos, ]); if (latestThreadInfo) { threadInfoRef.current = latestThreadInfo; } const threadInfo = threadInfoRef.current; const { setParams } = props.navigation; React.useEffect(() => { setParams({ threadInfo }); }, [setParams, threadInfo]); const threadID = threadInfoRef.current.id; const boundMessageListData = useSelector(messageListDataSelector(threadID)); const messageListData = React.useMemo( () => searching && userInfoInputArray.length === 0 ? [] : boundMessageListData, [boundMessageListData, searching, userInfoInputArray.length], ); const composedMessageMaxWidth = useSelector(composedMessageMaxWidthSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const overlayContext = React.useContext(OverlayContext); const messageListContext = useMessageListContext(threadID); return ( ); }); diff --git a/native/chat/message-preview.react.js b/native/chat/message-preview.react.js index b3e7ff048..2b6dc4918 100644 --- a/native/chat/message-preview.react.js +++ b/native/chat/message-preview.react.js @@ -1,104 +1,112 @@ // @flow +import invariant from 'invariant'; import PropTypes from 'prop-types'; import * as React from 'react'; import { Text } from 'react-native'; import { messagePreviewText } from 'lib/shared/message-utils'; import { threadIsGroupChat } from 'lib/shared/thread-utils'; import { stringForUser } from 'lib/shared/user-utils'; import { type MessageInfo, messageInfoPropType, messageTypes, } from 'lib/types/message-types'; import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; import { connect } from 'lib/utils/redux-utils'; import { firstLine } from 'lib/utils/string-utils'; import { SingleLine } from '../components/single-line.react'; import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; type Props = {| messageInfo: MessageInfo, threadInfo: ThreadInfo, // Redux state styles: typeof styles, |}; class MessagePreview extends React.PureComponent { static propTypes = { messageInfo: messageInfoPropType.isRequired, threadInfo: threadInfoPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { - const messageInfo: MessageInfo = this.props.messageInfo; + const messageInfo: MessageInfo = + this.props.messageInfo.type === messageTypes.SIDEBAR_SOURCE + ? this.props.messageInfo.initialMessage + : this.props.messageInfo; const unreadStyle = this.props.threadInfo.currentUser.unread ? this.props.styles.unread : null; if (messageInfo.type === messageTypes.TEXT) { let usernameText = null; if ( threadIsGroupChat(this.props.threadInfo) || this.props.threadInfo.name !== '' || messageInfo.creator.isViewer ) { const userString = stringForUser(messageInfo.creator); const username = `${userString}: `; usernameText = ( {username} ); } const firstMessageLine = firstLine(messageInfo.text); return ( {usernameText} {firstMessageLine} ); } else { + invariant( + messageInfo.type !== messageTypes.SIDEBAR_SOURCE, + 'Sidebar source should not be handled here', + ); const preview = messagePreviewText(messageInfo, this.props.threadInfo); return ( {preview} ); } } } const styles = { lastMessage: { color: 'listForegroundTertiaryLabel', flex: 1, fontSize: 16, paddingLeft: 10, }, preview: { color: 'listForegroundQuaternaryLabel', }, unread: { color: 'listForegroundLabel', }, username: { color: 'listForegroundQuaternaryLabel', }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(MessagePreview); diff --git a/native/components/node-height-measurer.react.js b/native/components/node-height-measurer.react.js index c0071f0eb..647981bc2 100644 --- a/native/components/node-height-measurer.react.js +++ b/native/components/node-height-measurer.react.js @@ -1,462 +1,461 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import shallowequal from 'shallowequal'; import type { Shape } from 'lib/types/core'; import type { LayoutEvent } from '../types/react-native'; const measureBatchSize = 50; type MergedItemPair = {| +item: Item, +mergedItem: MergedItem, |}; type Props = { // What we want to render +listData: ?$ReadOnlyArray, // Every item should have an ID. We use this ID to cache the result of calling // mergeItemWithHeight below, and only update it if the input item changes, // mergeItemWithHeight changes, or any extra props we get passed change +itemToID: (Item) => string, // Only measurable items should return a measureKey. // Falsey keys won't get measured, but will still get passed through // mergeItemWithHeight with height undefined // Make sure that if an item's height changes, its measure key does too! +itemToMeasureKey: (Item) => ?string, // The "dummy" is the component whose height we will be measuring // We will only call this with items for which itemToMeasureKey returns truthy +itemToDummy: (Item) => React.Element, // Once we have the height, we need to merge it into the item +mergeItemWithHeight: (item: Item, height: ?number) => MergedItem, // We'll pass our results here when we're done +allHeightsMeasured: (items: $ReadOnlyArray) => mixed, ... }; type State = {| // These are the dummies currently being rendered +currentlyMeasuring: $ReadOnlyArray<{| +measureKey: string, +dummy: React.Element, |}>, // When certain parameters change we need to remeasure everything. In order to // avoid considering any onLayouts that got queued before we issued the // remeasure, we increment the "iteration" and only count onLayouts with the // right value +iteration: number, // We cache the measured heights here, keyed by measure key +measuredHeights: Map, // We cache the results of calling mergeItemWithHeight on measured items after // measuring their height, keyed by ID +measurableItems: Map>, - +measurableItems: Map>, // We cache the results of calling mergeItemWithHeight on items that aren't // measurable (eg. itemToKey reurns falsey), keyed by ID +unmeasurableItems: Map>, |}; class NodeHeightMeasurer extends React.PureComponent< Props, State, > { static propTypes = { listData: PropTypes.arrayOf(PropTypes.object), itemToID: PropTypes.func.isRequired, itemToMeasureKey: PropTypes.func.isRequired, itemToDummy: PropTypes.func.isRequired, mergeItemWithHeight: PropTypes.func.isRequired, allHeightsMeasured: PropTypes.func.isRequired, }; containerWidth: ?number; constructor(props: Props) { super(props); const { listData, itemToID, itemToMeasureKey, mergeItemWithHeight } = props; const unmeasurableItems = new Map(); if (listData) { for (const item of listData) { const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { continue; } const mergedItem = mergeItemWithHeight(item, undefined); unmeasurableItems.set(itemToID(item), { item, mergedItem }); } } this.state = { currentlyMeasuring: [], iteration: 0, measuredHeights: new Map(), measurableItems: new Map(), unmeasurableItems, }; } static getDerivedStateFromProps( props: Props, state: State, ) { return NodeHeightMeasurer.getPossibleStateUpdateForNextBatch< Item, MergedItem, >(props, state); } static getPossibleStateUpdateForNextBatch( props: Props, state: State, ): ?Shape> { const { currentlyMeasuring, measuredHeights } = state; let stillMeasuring = false; for (const { measureKey } of currentlyMeasuring) { const height = measuredHeights.get(measureKey); if (height === null || height === undefined) { stillMeasuring = true; break; } } if (stillMeasuring) { return null; } const { listData, itemToMeasureKey, itemToDummy } = props; const toMeasure = new Map(); if (listData) { for (const item of listData) { const measureKey = itemToMeasureKey(item); if (measureKey === null || measureKey === undefined) { continue; } const height = measuredHeights.get(measureKey); if (height !== null && height !== undefined) { continue; } const dummy = itemToDummy(item); toMeasure.set(measureKey, dummy); if (toMeasure.size === measureBatchSize) { break; } } } if (currentlyMeasuring.length === 0 && toMeasure.size === 0) { return null; } const nextCurrentlyMeasuring = []; for (const [measureKey, dummy] of toMeasure) { nextCurrentlyMeasuring.push({ measureKey, dummy }); } return { currentlyMeasuring: nextCurrentlyMeasuring, measuredHeights: new Map(measuredHeights), }; } possiblyIssueNewBatch() { const stateUpdate = NodeHeightMeasurer.getPossibleStateUpdateForNextBatch( this.props, this.state, ); if (stateUpdate) { this.setState(stateUpdate); } } componentDidMount() { this.triggerCallback( this.state.measurableItems, this.state.unmeasurableItems, false, ); } triggerCallback( measurableItems: Map>, unmeasurableItems: Map>, mustTrigger: boolean, ) { const { listData, itemToID, itemToMeasureKey, allHeightsMeasured, } = this.props; if (!listData) { return; } const result = []; for (const item of listData) { const id = itemToID(item); const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { const measurableItem = measurableItems.get(id); if (!measurableItem && !mustTrigger) { return; } invariant( measurableItem, `currentlyMeasuring empty but no result for ${id}`, ); result.push(measurableItem.mergedItem); } else { const unmeasurableItem = unmeasurableItems.get(id); if (!unmeasurableItem && !mustTrigger) { return; } invariant( unmeasurableItem, `currentlyMeasuring empty but no result for ${id}`, ); result.push(unmeasurableItem.mergedItem); } } allHeightsMeasured(result); } componentDidUpdate( prevProps: Props, prevState: State, ) { const { listData, itemToID, itemToMeasureKey, itemToDummy, mergeItemWithHeight, allHeightsMeasured, ...rest } = this.props; const { listData: prevListData, itemToID: prevItemToID, itemToMeasureKey: prevItemToMeasureKey, itemToDummy: prevItemToDummy, mergeItemWithHeight: prevMergeItemWithHeight, allHeightsMeasured: prevAllHeightsMeasured, ...prevRest } = prevProps; const restShallowEqual = shallowequal(rest, prevRest); const measurementJustCompleted = this.state.currentlyMeasuring.length === 0 && prevState.currentlyMeasuring.length !== 0; let incrementIteration = false; const nextMeasuredHeights = new Map(this.state.measuredHeights); let measuredHeightsChanged = false; const nextMeasurableItems = new Map(this.state.measurableItems); let measurableItemsChanged = false; const nextUnmeasurableItems = new Map(this.state.unmeasurableItems); let unmeasurableItemsChanged = false; if ( itemToMeasureKey !== prevItemToMeasureKey || itemToDummy !== prevItemToDummy ) { incrementIteration = true; nextMeasuredHeights.clear(); measuredHeightsChanged = true; } if ( itemToID !== prevItemToID || itemToMeasureKey !== prevItemToMeasureKey || itemToDummy !== prevItemToDummy || mergeItemWithHeight !== prevMergeItemWithHeight || !restShallowEqual ) { if (nextMeasurableItems.size > 0) { nextMeasurableItems.clear(); measurableItemsChanged = true; } } if ( itemToID !== prevItemToID || itemToMeasureKey !== prevItemToMeasureKey || mergeItemWithHeight !== prevMergeItemWithHeight || !restShallowEqual ) { if (nextUnmeasurableItems.size > 0) { nextUnmeasurableItems.clear(); unmeasurableItemsChanged = true; } } if ( measurementJustCompleted || listData !== prevListData || measuredHeightsChanged || measurableItemsChanged || unmeasurableItemsChanged ) { const currentMeasurableItems = new Map(); const currentUnmeasurableItems = new Map(); if (listData) { for (const item of listData) { const id = itemToID(item); const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { currentMeasurableItems.set(id, item); } else { currentUnmeasurableItems.set(id, item); } } } for (const [id, { item }] of nextMeasurableItems) { const currentItem = currentMeasurableItems.get(id); if (!currentItem) { measurableItemsChanged = true; nextMeasurableItems.delete(id); } else if (currentItem !== item) { measurableItemsChanged = true; const measureKey = itemToMeasureKey(currentItem); if (measureKey === null || measureKey === undefined) { nextMeasurableItems.delete(id); continue; } const height = nextMeasuredHeights.get(measureKey); if (height === null || height === undefined) { nextMeasurableItems.delete(id); continue; } const mergedItem = mergeItemWithHeight(currentItem, height); nextMeasurableItems.set(id, { item: currentItem, mergedItem }); } } for (const [id, item] of currentMeasurableItems) { if (nextMeasurableItems.has(id)) { continue; } const measureKey = itemToMeasureKey(item); if (measureKey === null || measureKey === undefined) { continue; } const height = nextMeasuredHeights.get(measureKey); if (height === null || height === undefined) { continue; } const mergedItem = mergeItemWithHeight(item, height); nextMeasurableItems.set(id, { item, mergedItem }); measurableItemsChanged = true; } for (const [id, { item }] of nextUnmeasurableItems) { const currentItem = currentUnmeasurableItems.get(id); if (!currentItem) { unmeasurableItemsChanged = true; nextUnmeasurableItems.delete(id); } else if (currentItem !== item) { unmeasurableItemsChanged = true; const measureKey = itemToMeasureKey(currentItem); if (measureKey !== null && measureKey !== undefined) { nextUnmeasurableItems.delete(id); continue; } const mergedItem = mergeItemWithHeight(currentItem, undefined); nextUnmeasurableItems.set(id, { item: currentItem, mergedItem }); } } for (const [id, item] of currentUnmeasurableItems) { if (nextUnmeasurableItems.has(id)) { continue; } const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { continue; } const mergedItem = mergeItemWithHeight(item, undefined); nextUnmeasurableItems.set(id, { item, mergedItem }); unmeasurableItemsChanged = true; } } const stateUpdate = {}; if (incrementIteration) { stateUpdate.iteration = this.state.iteration + 1; } if (measuredHeightsChanged) { stateUpdate.measuredHeights = nextMeasuredHeights; } if (measurableItemsChanged) { stateUpdate.measurableItems = nextMeasurableItems; } if (unmeasurableItemsChanged) { stateUpdate.unmeasurableItems = nextUnmeasurableItems; } if (Object.keys(stateUpdate).length > 0) { this.setState(stateUpdate); } if (measurementJustCompleted || !shallowequal(this.props, prevProps)) { this.triggerCallback( nextMeasurableItems, nextUnmeasurableItems, measurementJustCompleted, ); } } onContainerLayout = (event: LayoutEvent) => { const { width, height } = event.nativeEvent.layout; if (width > height) { // We currently only use NodeHeightMeasurer on interfaces that are // portrait-locked. If we expand beyond that we'll need to rethink this return; } if (this.containerWidth === undefined) { this.containerWidth = width; } else if (this.containerWidth !== width) { this.containerWidth = width; this.setState((innerPrevState) => ({ iteration: innerPrevState.iteration + 1, measuredHeights: new Map(), measurableItems: new Map(), })); } }; onDummyLayout(measureKey: string, iteration: number, event: LayoutEvent) { if (iteration !== this.state.iteration) { return; } const { height } = event.nativeEvent.layout; this.state.measuredHeights.set(measureKey, height); this.possiblyIssueNewBatch(); } render() { const { currentlyMeasuring, iteration } = this.state; const dummies = currentlyMeasuring.map(({ measureKey, dummy }) => { const { children } = dummy.props; const style = [dummy.props.style, styles.dummy]; const onLayout = (event) => this.onDummyLayout(measureKey, iteration, event); const node = React.cloneElement(dummy, { style, onLayout, children, }); return {node}; }); return {dummies}; } } const styles = StyleSheet.create({ dummy: { opacity: 0, position: 'absolute', }, }); export default NodeHeightMeasurer; diff --git a/server/src/creators/message-creator.js b/server/src/creators/message-creator.js index bdf422144..316bcf503 100644 --- a/server/src/creators/message-creator.js +++ b/server/src/creators/message-creator.js @@ -1,567 +1,574 @@ // @flow import invariant from 'invariant'; import { permissionLookup } from 'lib/permissions/thread-permissions'; import { rawMessageInfoFromMessageData, messageTypeGeneratesNotifs, shimUnsupportedRawMessageInfos, stripLocalIDs, } from 'lib/shared/message-utils'; import { messageTypes, messageDataLocalID, type MessageData, type RawMessageInfo, } from 'lib/types/message-types'; import { redisMessageTypes } from 'lib/types/redis-types'; import { threadPermissions } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import { dbQuery, SQL, appendSQLArray, mergeOrConditions, } from '../database/database'; import { fetchMessageInfoForLocalID } from '../fetchers/message-fetchers'; import { fetchOtherSessionsForViewer } from '../fetchers/session-fetchers'; import { sendPushNotifs } from '../push/send'; import { handleAsyncPromise } from '../responders/handlers'; import type { Viewer } from '../session/viewer'; import { earliestFocusedTimeConsideredCurrent } from '../shared/focused-times'; import { publisher } from '../socket/redis'; import { creationString } from '../utils/idempotent'; import createIDs from './id-creator'; import type { UpdatesForCurrentSession } from './update-creator'; import { createUpdates } from './update-creator'; type UserThreadInfo = {| +devices: Map< string, {| +deviceType: string, +deviceToken: string, +codeVersion: ?string, |}, >, +threadIDs: Set, +notFocusedThreadIDs: Set, +subthreadsCanNotify: Set, +subthreadsCanSetToUnread: Set, |}; type LatestMessagesPerUser = Map< string, $ReadOnlyMap< string, {| +latestMessage: string, +latestReadMessage?: string, |}, >, >; type LatestMessages = $ReadOnlyArray<{| +userID: string, +threadID: string, +latestMessage: string, +latestReadMessage: ?string, |}>; // Does not do permission checks! (checkThreadPermission) async function createMessages( viewer: Viewer, messageDatas: $ReadOnlyArray, updatesForCurrentSession?: UpdatesForCurrentSession = 'return', ): Promise { if (messageDatas.length === 0) { return []; } const messageInfos: RawMessageInfo[] = []; const newMessageDatas: MessageData[] = []; const existingMessages = await Promise.all( messageDatas.map((messageData) => fetchMessageInfoForLocalID(viewer, messageDataLocalID(messageData)), ), ); for (let i = 0; i < existingMessages.length; i++) { const existingMessage = existingMessages[i]; if (existingMessage) { messageInfos.push(existingMessage); } else { newMessageDatas.push(messageDatas[i]); } } if (newMessageDatas.length === 0) { return shimUnsupportedRawMessageInfos(messageInfos, viewer.platformDetails); } const ids = await createIDs('messages', newMessageDatas.length); const subthreadPermissionsToCheck: Set = new Set(); const threadsToMessageIndices: Map = new Map(); const messageInsertRows = []; for (let i = 0; i < newMessageDatas.length; i++) { const messageData = newMessageDatas[i]; const threadID = messageData.threadID; const creatorID = messageData.creatorID; if (messageData.type === messageTypes.CREATE_SUB_THREAD) { subthreadPermissionsToCheck.add(messageData.childThreadID); } let messageIndices = threadsToMessageIndices.get(threadID); if (!messageIndices) { messageIndices = []; threadsToMessageIndices.set(threadID, messageIndices); } messageIndices.push(i); let content; - if (messageData.type === messageTypes.CREATE_THREAD) { + if ( + messageData.type === messageTypes.CREATE_THREAD || + messageData.type === messageTypes.CREATE_SIDEBAR + ) { content = JSON.stringify(messageData.initialThreadState); } else if (messageData.type === messageTypes.CREATE_SUB_THREAD) { content = messageData.childThreadID; } else if (messageData.type === messageTypes.TEXT) { content = messageData.text; } else if (messageData.type === messageTypes.ADD_MEMBERS) { content = JSON.stringify(messageData.addedUserIDs); } else if (messageData.type === messageTypes.CHANGE_SETTINGS) { content = JSON.stringify({ [messageData.field]: messageData.value, }); } else if (messageData.type === messageTypes.REMOVE_MEMBERS) { content = JSON.stringify(messageData.removedUserIDs); } else if (messageData.type === messageTypes.CHANGE_ROLE) { content = JSON.stringify({ userIDs: messageData.userIDs, newRole: messageData.newRole, }); } else if ( messageData.type === messageTypes.CREATE_ENTRY || messageData.type === messageTypes.EDIT_ENTRY || messageData.type === messageTypes.DELETE_ENTRY || messageData.type === messageTypes.RESTORE_ENTRY ) { content = JSON.stringify({ entryID: messageData.entryID, date: messageData.date, text: messageData.text, }); } else if ( messageData.type === messageTypes.IMAGES || messageData.type === messageTypes.MULTIMEDIA ) { const mediaIDs = []; for (const { id } of messageData.media) { mediaIDs.push(parseInt(id, 10)); } content = JSON.stringify(mediaIDs); } else if (messageData.type === messageTypes.UPDATE_RELATIONSHIP) { content = JSON.stringify({ operation: messageData.operation, targetID: messageData.targetID, }); + } else if (messageData.type === messageTypes.SIDEBAR_SOURCE) { + content = JSON.stringify({ + initialMessageID: messageData.initialMessage.id, + }); } const creation = messageData.localID && viewer.hasSessionInfo ? creationString(viewer, messageData.localID) : null; messageInsertRows.push([ ids[i], threadID, creatorID, messageData.type, content, messageData.time, creation, ]); messageInfos.push(rawMessageInfoFromMessageData(messageData, ids[i])); } if (viewer.isScriptViewer) { await postMessageSend( viewer, threadsToMessageIndices, subthreadPermissionsToCheck, stripLocalIDs(messageInfos), updatesForCurrentSession, ); } else { // We aren't awaiting because this function calls external services and we // don't want to delay the response handleAsyncPromise( postMessageSend( viewer, threadsToMessageIndices, subthreadPermissionsToCheck, stripLocalIDs(messageInfos), updatesForCurrentSession, ), ); } const messageInsertQuery = SQL` INSERT INTO messages(id, thread, user, type, content, time, creation) VALUES ${messageInsertRows} `; await dbQuery(messageInsertQuery); if (updatesForCurrentSession !== 'return') { return []; } return shimUnsupportedRawMessageInfos(messageInfos, viewer.platformDetails); } // Handles: // (1) Sending push notifs // (2) Setting threads to unread and generating corresponding UpdateInfos // (3) Publishing to Redis so that active sockets pass on new messages async function postMessageSend( viewer: Viewer, threadsToMessageIndices: Map, subthreadPermissionsToCheck: Set, messageInfos: RawMessageInfo[], updatesForCurrentSession: UpdatesForCurrentSession, ) { let joinIndex = 0; let subthreadSelects = ''; const subthreadJoins = []; for (const subthread of subthreadPermissionsToCheck) { const index = joinIndex++; subthreadSelects += ` , stm${index}.permissions AS subthread${subthread}_permissions, stm${index}.role AS subthread${subthread}_role `; const join = SQL`LEFT JOIN memberships `; join.append(`stm${index} ON stm${index}.`); join.append(SQL`thread = ${subthread} AND `); join.append(`stm${index}.user = m.user`); subthreadJoins.push(join); } const time = earliestFocusedTimeConsideredCurrent(); const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; const query = SQL` SELECT m.user, m.thread, c.platform, c.device_token, c.versions, f.user AS focused_user `; query.append(subthreadSelects); query.append(SQL` FROM memberships m LEFT JOIN cookies c ON c.user = m.user AND c.device_token IS NOT NULL LEFT JOIN focused f ON f.user = m.user AND f.thread = m.thread AND f.time > ${time} `); appendSQLArray(query, subthreadJoins, SQL` `); query.append(SQL` WHERE (m.role > 0 OR f.user IS NOT NULL) AND JSON_EXTRACT(m.permissions, ${visibleExtractString}) IS TRUE AND m.thread IN (${[...threadsToMessageIndices.keys()]}) `); const perUserInfo = new Map(); const [result] = await dbQuery(query); for (const row of result) { const userID = row.user.toString(); const threadID = row.thread.toString(); const deviceToken = row.device_token; const focusedUser = !!row.focused_user; const { platform, versions } = row; let thisUserInfo = perUserInfo.get(userID); if (!thisUserInfo) { thisUserInfo = { devices: new Map(), threadIDs: new Set(), notFocusedThreadIDs: new Set(), subthreadsCanNotify: new Set(), subthreadsCanSetToUnread: new Set(), }; perUserInfo.set(userID, thisUserInfo); // Subthread info will be the same for each subthread, so we only parse // it once for (const subthread of subthreadPermissionsToCheck) { const isSubthreadMember = row[`subthread${subthread}_role`] > 0; const permissions = row[`subthread${subthread}_permissions`]; const canSeeSubthread = permissionLookup( permissions, threadPermissions.KNOW_OF, ); if (!canSeeSubthread) { continue; } thisUserInfo.subthreadsCanSetToUnread.add(subthread); // Only include the notification from the superthread if there is no // notification from the subthread if ( !isSubthreadMember || !permissionLookup(permissions, threadPermissions.VISIBLE) ) { thisUserInfo.subthreadsCanNotify.add(subthread); } } } if (deviceToken) { thisUserInfo.devices.set(deviceToken, { deviceType: platform, deviceToken, codeVersion: versions ? versions.codeVersion : null, }); } thisUserInfo.threadIDs.add(threadID); if (!focusedUser) { thisUserInfo.notFocusedThreadIDs.add(threadID); } } const pushInfo = {}; const messageInfosPerUser = {}; const latestMessagesPerUser: LatestMessagesPerUser = new Map(); for (const pair of perUserInfo) { const [userID, preUserPushInfo] = pair; const { subthreadsCanNotify } = preUserPushInfo; const userPushInfo = { devices: [...preUserPushInfo.devices.values()], messageInfos: [], }; for (const threadID of preUserPushInfo.notFocusedThreadIDs) { const messageIndices = threadsToMessageIndices.get(threadID); invariant(messageIndices, `indices should exist for thread ${threadID}`); for (const messageIndex of messageIndices) { const messageInfo = messageInfos[messageIndex]; if ( (messageInfo.type !== messageTypes.CREATE_SUB_THREAD || subthreadsCanNotify.has(messageInfo.childThreadID)) && messageTypeGeneratesNotifs(messageInfo.type) && messageInfo.creatorID !== userID ) { userPushInfo.messageInfos.push(messageInfo); } } } if ( userPushInfo.devices.length > 0 && userPushInfo.messageInfos.length > 0 ) { pushInfo[userID] = userPushInfo; } const userMessageInfos = []; for (const threadID of preUserPushInfo.threadIDs) { const messageIndices = threadsToMessageIndices.get(threadID); invariant(messageIndices, `indices should exist for thread ${threadID}`); for (const messageIndex of messageIndices) { const messageInfo = messageInfos[messageIndex]; userMessageInfos.push(messageInfo); } } if (userMessageInfos.length > 0) { messageInfosPerUser[userID] = userMessageInfos; } latestMessagesPerUser.set( userID, determineLatestMessagesPerThread( preUserPushInfo, userID, threadsToMessageIndices, messageInfos, ), ); } const latestMessages = flattenLatestMessagesPerUser(latestMessagesPerUser); await Promise.all([ createReadStatusUpdates(latestMessages), redisPublish(viewer, messageInfosPerUser, updatesForCurrentSession), updateLatestMessages(latestMessages), ]); await sendPushNotifs(pushInfo); } async function redisPublish( viewer: Viewer, messageInfosPerUser: { [userID: string]: $ReadOnlyArray }, updatesForCurrentSession: UpdatesForCurrentSession, ) { const avoidBroadcastingToCurrentSession = viewer.hasSessionInfo && updatesForCurrentSession !== 'broadcast'; for (const userID in messageInfosPerUser) { if (userID === viewer.userID && avoidBroadcastingToCurrentSession) { continue; } const messageInfos = messageInfosPerUser[userID]; publisher.sendMessage( { userID }, { type: redisMessageTypes.NEW_MESSAGES, messages: messageInfos, }, ); } const viewerMessageInfos = messageInfosPerUser[viewer.userID]; if (!viewerMessageInfos || !avoidBroadcastingToCurrentSession) { return; } const sessionIDs = await fetchOtherSessionsForViewer(viewer); for (const sessionID of sessionIDs) { publisher.sendMessage( { userID: viewer.userID, sessionID }, { type: redisMessageTypes.NEW_MESSAGES, messages: viewerMessageInfos, }, ); } } function determineLatestMessagesPerThread( preUserPushInfo: UserThreadInfo, userID: string, threadsToMessageIndices: $ReadOnlyMap>, messageInfos: $ReadOnlyArray, ) { const { threadIDs, notFocusedThreadIDs, subthreadsCanSetToUnread, } = preUserPushInfo; const latestMessagesPerThread = new Map(); for (const threadID of threadIDs) { const messageIndices = threadsToMessageIndices.get(threadID); invariant(messageIndices, `indices should exist for thread ${threadID}`); for (const messageIndex of messageIndices) { const messageInfo = messageInfos[messageIndex]; if ( messageInfo.type === messageTypes.CREATE_SUB_THREAD && !subthreadsCanSetToUnread.has(messageInfo.childThreadID) ) { continue; } const messageID = messageInfo.id; invariant( messageID, 'message ID should exist in determineLatestMessagesPerThread', ); if ( notFocusedThreadIDs.has(threadID) && messageInfo.creatorID !== userID ) { latestMessagesPerThread.set(threadID, { latestMessage: messageID, }); } else { latestMessagesPerThread.set(threadID, { latestMessage: messageID, latestReadMessage: messageID, }); } } } return latestMessagesPerThread; } function flattenLatestMessagesPerUser( latestMessagesPerUser: LatestMessagesPerUser, ): LatestMessages { const result = []; for (const [userID, latestMessagesPerThread] of latestMessagesPerUser) { for (const [threadID, latestMessages] of latestMessagesPerThread) { result.push({ userID, threadID, latestMessage: latestMessages.latestMessage, latestReadMessage: latestMessages.latestReadMessage, }); } } return result; } async function createReadStatusUpdates(latestMessages: LatestMessages) { const now = Date.now(); const readStatusUpdates = latestMessages .filter((message) => !message.latestReadMessage) .map(({ userID, threadID }) => ({ type: updateTypes.UPDATE_THREAD_READ_STATUS, userID, time: now, threadID, unread: true, })); if (readStatusUpdates.length === 0) { return; } return await createUpdates(readStatusUpdates); } function updateLatestMessages(latestMessages: LatestMessages) { if (latestMessages.length === 0) { return; } const query = SQL` UPDATE memberships SET `; const lastMessageExpression = SQL` last_message = GREATEST(last_message, CASE `; const lastReadMessageExpression = SQL` , last_read_message = GREATEST(last_read_message, CASE `; let shouldUpdateLastReadMessage = false; for (const { userID, threadID, latestMessage, latestReadMessage, } of latestMessages) { lastMessageExpression.append(SQL` WHEN user = ${userID} AND thread = ${threadID} THEN ${latestMessage} `); if (latestReadMessage) { shouldUpdateLastReadMessage = true; lastReadMessageExpression.append(SQL` WHEN user = ${userID} AND thread = ${threadID} THEN ${latestReadMessage} `); } } lastMessageExpression.append(SQL` ELSE last_message END) `); lastReadMessageExpression.append(SQL` ELSE last_read_message END) `); const conditions = latestMessages.map( ({ userID, threadID }) => SQL`(user = ${userID} AND thread = ${threadID})`, ); query.append(lastMessageExpression); if (shouldUpdateLastReadMessage) { query.append(lastReadMessageExpression); } query.append(SQL`WHERE `); query.append(mergeOrConditions(conditions)); return dbQuery(query); } export default createMessages; diff --git a/server/src/fetchers/message-fetchers.js b/server/src/fetchers/message-fetchers.js index ee6ec70a2..6a0cf0b2d 100644 --- a/server/src/fetchers/message-fetchers.js +++ b/server/src/fetchers/message-fetchers.js @@ -1,712 +1,740 @@ // @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]); } } for (const userRows of rowsByUser.values()) { - const messages = parseMessageSQLResult(userRows); + const messages = await parseMessageSQLResult(userRows); 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( +async function parseMessageSQLResult( rows: $ReadOnlyArray, viewer?: Viewer, -): MessageSQLResult { +): Promise { 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); + const rawMessageInfo = await rawMessageInfoFromRows(messageRows, viewer); 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( +async function rawMessageInfoFromRows( rows: $ReadOnlyArray, viewer?: ?Viewer, -): ?RawMessageInfo { +): Promise { 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 = await fetchMessageInfoByID( + viewer, + content.initialMessageID, + ); + 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 messages = parseMessageSQLResult(result, viewer); + const messages = await parseMessageSQLResult(result, 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) { - // If a CREATE_THREAD message for a given thread is in the result set, - // then our result set includes all messages in the query range for that - // thread + 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 messages = parseMessageSQLResult(result, viewer); + const messages = await parseMessageSQLResult(result, 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) { - // If a CREATE_THREAD message is here, then we have all messages + 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; } return rawMessageInfoFromRows(result, viewer); } 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; } return rawMessageInfoFromRows(result, viewer); } async function fetchMessageInfoByID( - viewer: Viewer, + viewer?: ?Viewer, messageID: string, ): Promise { - if (!viewer.hasSessionInfo) { - return null; - } - 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 + 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 = ${messageID} `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } return rawMessageInfoFromRows(result, viewer); } export { fetchCollapsableNotifs, fetchMessageInfos, fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, fetchMessageInfoForLocalID, fetchMessageInfoForEntryAction, fetchMessageInfoByID, }; diff --git a/server/src/push/send.js b/server/src/push/send.js index 2113b0541..8f62d629c 100644 --- a/server/src/push/send.js +++ b/server/src/push/send.js @@ -1,806 +1,810 @@ // @flow import apn from '@parse/node-apn'; import invariant from 'invariant'; import _flow from 'lodash/fp/flow'; import _mapValues from 'lodash/fp/mapValues'; import _pickBy from 'lodash/fp/pickBy'; import uuidv4 from 'uuid/v4'; import { oldValidUsernameRegex } from 'lib/shared/account-utils'; import { createMessageInfo, sortMessageInfoList, shimUnsupportedRawMessageInfos, } from 'lib/shared/message-utils'; import { notifTextsForMessageInfo } from 'lib/shared/notif-utils'; import { rawThreadInfoFromServerThreadInfo, threadInfoFromRawThreadInfo, } from 'lib/shared/thread-utils'; import type { DeviceType } from 'lib/types/device-types'; import { type RawMessageInfo, type MessageInfo, messageTypes, } from 'lib/types/message-types'; import type { ServerThreadInfo, ThreadInfo } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import { promiseAll } from 'lib/utils/promises'; import createIDs from '../creators/id-creator'; import { createUpdates } from '../creators/update-creator'; import { dbQuery, SQL, mergeOrConditions } from '../database/database'; import type { CollapsableNotifInfo } from '../fetchers/message-fetchers'; import { fetchCollapsableNotifs } from '../fetchers/message-fetchers'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers'; import { fetchUserInfos } from '../fetchers/user-fetchers'; import type { Viewer } from '../session/viewer'; import { apnPush, fcmPush, getUnreadCounts } from './utils'; type Device = {| +deviceType: DeviceType, +deviceToken: string, +codeVersion: ?number, |}; type PushUserInfo = {| +devices: Device[], +messageInfos: RawMessageInfo[], |}; type Delivery = IOSDelivery | AndroidDelivery | {| collapsedInto: string |}; type NotificationRow = {| +dbID: string, +userID: string, +threadID?: ?string, +messageID?: ?string, +collapseKey?: ?string, +deliveries: Delivery[], |}; export type PushInfo = { [userID: string]: PushUserInfo }; async function sendPushNotifs(pushInfo: PushInfo) { if (Object.keys(pushInfo).length === 0) { return; } const [ unreadCounts, { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }, dbIDs, ] = await Promise.all([ getUnreadCounts(Object.keys(pushInfo)), fetchInfos(pushInfo), createDBIDs(pushInfo), ]); const deliveryPromises = []; const notifications: Map = new Map(); for (let userID in usersToCollapsableNotifInfo) { const threadInfos = _flow( _mapValues((serverThreadInfo: ServerThreadInfo) => { const rawThreadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, userID, ); if (!rawThreadInfo) { return null; } return threadInfoFromRawThreadInfo(rawThreadInfo, userID, userInfos); }), _pickBy((threadInfo) => threadInfo), )(serverThreadInfos); for (let notifInfo of usersToCollapsableNotifInfo[userID]) { const hydrateMessageInfo = (rawMessageInfo: RawMessageInfo) => createMessageInfo(rawMessageInfo, userID, userInfos, threadInfos); const newMessageInfos = []; const newRawMessageInfos = []; for (let newRawMessageInfo of notifInfo.newMessageInfos) { const newMessageInfo = hydrateMessageInfo(newRawMessageInfo); if (newMessageInfo) { newMessageInfos.push(newMessageInfo); newRawMessageInfos.push(newRawMessageInfo); } } if (newMessageInfos.length === 0) { continue; } const existingMessageInfos = notifInfo.existingMessageInfos .map(hydrateMessageInfo) .filter(Boolean); const allMessageInfos = sortMessageInfoList([ ...newMessageInfos, ...existingMessageInfos, ]); const [ firstNewMessageInfo, ...remainingNewMessageInfos ] = newMessageInfos; const threadID = firstNewMessageInfo.threadID; const threadInfo = threadInfos[threadID]; const updateBadge = threadInfo.currentUser.subscription.home; const displayBanner = threadInfo.currentUser.subscription.pushNotifs; const username = userInfos[userID] && userInfos[userID].username; const userWasMentioned = username && threadInfo.currentUser.role && oldValidUsernameRegex.test(username) && firstNewMessageInfo.type === messageTypes.TEXT && new RegExp(`\\B@${username}\\b`, 'i').test(firstNewMessageInfo.text); if (!updateBadge && !displayBanner && !userWasMentioned) { continue; } const badgeOnly = !displayBanner && !userWasMentioned; const dbID = dbIDs.shift(); invariant(dbID, 'should have sufficient DB IDs'); const byDeviceType = getDevicesByDeviceType(pushInfo[userID].devices); const firstMessageID = firstNewMessageInfo.id; invariant(firstMessageID, 'RawMessageInfo.id should be set on server'); const notificationInfo = { source: 'new_message', dbID, userID, threadID, messageID: firstMessageID, collapseKey: notifInfo.collapseKey, }; const iosVersionsToTokens = byDeviceType.get('ios'); if (iosVersionsToTokens) { for (const [codeVer, deviceTokens] of iosVersionsToTokens) { const codeVersion = parseInt(codeVer, 10); // only for Flow const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, { platform: 'ios', codeVersion }, ); const notification = prepareIOSNotification( allMessageInfos, shimmedNewRawMessageInfos, threadInfo, notifInfo.collapseKey, badgeOnly, unreadCounts[userID], ); deliveryPromises.push( sendIOSNotification(notification, [...deviceTokens], { ...notificationInfo, codeVersion, }), ); } } const androidVersionsToTokens = byDeviceType.get('android'); if (androidVersionsToTokens) { for (const [codeVer, deviceTokens] of androidVersionsToTokens) { const codeVersion = parseInt(codeVer, 10); // only for Flow const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, { platform: 'android', codeVersion }, ); const notification = prepareAndroidNotification( allMessageInfos, shimmedNewRawMessageInfos, threadInfo, notifInfo.collapseKey, badgeOnly, unreadCounts[userID], dbID, codeVersion, ); deliveryPromises.push( sendAndroidNotification(notification, [...deviceTokens], { ...notificationInfo, codeVersion, }), ); } } for (let newMessageInfo of remainingNewMessageInfos) { const newDBID = dbIDs.shift(); invariant(newDBID, 'should have sufficient DB IDs'); const messageID = newMessageInfo.id; invariant(messageID, 'RawMessageInfo.id should be set on server'); notifications.set(newDBID, { dbID: newDBID, userID, threadID: newMessageInfo.threadID, messageID, collapseKey: notifInfo.collapseKey, deliveries: [{ collapsedInto: dbID }], }); } } } const cleanUpPromises = []; if (dbIDs.length > 0) { const query = SQL`DELETE FROM ids WHERE id IN (${dbIDs})`; cleanUpPromises.push(dbQuery(query)); } const [deliveryResults] = await Promise.all([ Promise.all(deliveryPromises), Promise.all(cleanUpPromises), ]); await saveNotifResults(deliveryResults, notifications, true); } // The results in deliveryResults will be combined with the rows // in rowsToSave and then written to the notifications table async function saveNotifResults( deliveryResults: $ReadOnlyArray, inputRowsToSave: Map, rescindable: boolean, ) { const rowsToSave = new Map(inputRowsToSave); const allInvalidTokens = []; for (const deliveryResult of deliveryResults) { const { info, delivery, invalidTokens } = deliveryResult; const { dbID, userID } = info; const curNotifRow = rowsToSave.get(dbID); if (curNotifRow) { curNotifRow.deliveries.push(delivery); } else { // Ternary expressions for Flow const threadID = info.threadID ? info.threadID : null; const messageID = info.messageID ? info.messageID : null; const collapseKey = info.collapseKey ? info.collapseKey : null; rowsToSave.set(dbID, { dbID, userID, threadID, messageID, collapseKey, deliveries: [delivery], }); } if (invalidTokens) { allInvalidTokens.push({ userID, tokens: invalidTokens, }); } } const notificationRows = []; for (const notification of rowsToSave.values()) { notificationRows.push([ notification.dbID, notification.userID, notification.threadID, notification.messageID, notification.collapseKey, JSON.stringify(notification.deliveries), Number(!rescindable), ]); } const dbPromises = []; if (allInvalidTokens.length > 0) { dbPromises.push(removeInvalidTokens(allInvalidTokens)); } if (notificationRows.length > 0) { const query = SQL` INSERT INTO notifications (id, user, thread, message, collapse_key, delivery, rescinded) VALUES ${notificationRows} `; dbPromises.push(dbQuery(query)); } if (dbPromises.length > 0) { await Promise.all(dbPromises); } } async function fetchInfos(pushInfo: PushInfo) { const usersToCollapsableNotifInfo = await fetchCollapsableNotifs(pushInfo); const threadIDs = new Set(); const threadWithChangedNamesToMessages = new Map(); const addThreadIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { const threadID = rawMessageInfo.threadID; threadIDs.add(threadID); if ( - rawMessageInfo.type === messageTypes.CREATE_THREAD && + (rawMessageInfo.type === messageTypes.CREATE_THREAD || + rawMessageInfo.type === messageTypes.CREATE_SIDEBAR) && rawMessageInfo.initialThreadState.parentThreadID ) { threadIDs.add(rawMessageInfo.initialThreadState.parentThreadID); } else if (rawMessageInfo.type === messageTypes.CREATE_SUB_THREAD) { threadIDs.add(rawMessageInfo.childThreadID); } if ( rawMessageInfo.type === messageTypes.CHANGE_SETTINGS && rawMessageInfo.field === 'name' ) { const messages = threadWithChangedNamesToMessages.get(threadID); if (messages) { messages.push(rawMessageInfo.id); } else { threadWithChangedNamesToMessages.set(threadID, [rawMessageInfo.id]); } } }; for (let userID in usersToCollapsableNotifInfo) { for (let notifInfo of usersToCollapsableNotifInfo[userID]) { for (let rawMessageInfo of notifInfo.existingMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } for (let rawMessageInfo of notifInfo.newMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } } } const promises = {}; // These threadInfos won't have currentUser set promises.threadResult = fetchServerThreadInfos( SQL`t.id IN (${[...threadIDs]})`, ); if (threadWithChangedNamesToMessages.size > 0) { const typesThatAffectName = [ messageTypes.CHANGE_SETTINGS, messageTypes.CREATE_THREAD, ]; const oldNameQuery = SQL` SELECT IF( JSON_TYPE(JSON_EXTRACT(m.content, "$.name")) = 'NULL', "", JSON_UNQUOTE(JSON_EXTRACT(m.content, "$.name")) ) AS name, m.thread FROM ( SELECT MAX(id) AS id FROM messages WHERE type IN (${typesThatAffectName}) AND JSON_EXTRACT(content, "$.name") IS NOT NULL AND`; const threadClauses = []; for (let [threadID, messages] of threadWithChangedNamesToMessages) { threadClauses.push( SQL`(thread = ${threadID} AND id NOT IN (${messages}))`, ); } oldNameQuery.append(mergeOrConditions(threadClauses)); oldNameQuery.append(SQL` GROUP BY thread ) x LEFT JOIN messages m ON m.id = x.id `); promises.oldNames = dbQuery(oldNameQuery); } const { threadResult, oldNames } = await promiseAll(promises); const serverThreadInfos = threadResult.threadInfos; if (oldNames) { const [result] = oldNames; for (let row of result) { const threadID = row.thread.toString(); serverThreadInfos[threadID].name = row.name; } } const userInfos = await fetchNotifUserInfos( serverThreadInfos, usersToCollapsableNotifInfo, ); return { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }; } async function fetchNotifUserInfos( serverThreadInfos: { [threadID: string]: ServerThreadInfo }, usersToCollapsableNotifInfo: { [userID: string]: CollapsableNotifInfo[] }, ) { const missingUserIDs = new Set(); for (const threadID in serverThreadInfos) { const serverThreadInfo = serverThreadInfos[threadID]; for (const member of serverThreadInfo.members) { missingUserIDs.add(member.id); } } const addUserIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { missingUserIDs.add(rawMessageInfo.creatorID); if (rawMessageInfo.type === messageTypes.ADD_MEMBERS) { for (const userID of rawMessageInfo.addedUserIDs) { missingUserIDs.add(userID); } } else if (rawMessageInfo.type === messageTypes.REMOVE_MEMBERS) { for (const userID of rawMessageInfo.removedUserIDs) { missingUserIDs.add(userID); } - } else if (rawMessageInfo.type === messageTypes.CREATE_THREAD) { + } else if ( + rawMessageInfo.type === messageTypes.CREATE_THREAD || + rawMessageInfo.type === messageTypes.CREATE_SIDEBAR + ) { for (const userID of rawMessageInfo.initialThreadState.memberIDs) { missingUserIDs.add(userID); } } }; for (const userID in usersToCollapsableNotifInfo) { for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const rawMessageInfo of notifInfo.existingMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } for (const rawMessageInfo of notifInfo.newMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } } } return await fetchUserInfos([...missingUserIDs]); } async function createDBIDs(pushInfo: PushInfo): Promise { let numIDsNeeded = 0; for (let userID in pushInfo) { numIDsNeeded += pushInfo[userID].messageInfos.length; } return await createIDs('notifications', numIDsNeeded); } function getDevicesByDeviceType( devices: Device[], ): Map>> { const byDeviceType = new Map(); for (let device of devices) { let innerMap = byDeviceType.get(device.deviceType); if (!innerMap) { innerMap = new Map(); byDeviceType.set(device.deviceType, innerMap); } const codeVersion: number = device.codeVersion !== null && device.codeVersion !== undefined ? device.codeVersion : -1; let innerMostSet = innerMap.get(codeVersion); if (!innerMostSet) { innerMostSet = new Set(); innerMap.set(codeVersion, innerMostSet); } innerMostSet.add(device.deviceToken); } return byDeviceType; } function prepareIOSNotification( allMessageInfos: MessageInfo[], newRawMessageInfos: RawMessageInfo[], threadInfo: ThreadInfo, collapseKey: ?string, badgeOnly: boolean, unreadCount: number, ): apn.Notification { const uniqueID = uuidv4(); const notification = new apn.Notification(); notification.topic = 'org.squadcal.app'; const { merged, ...rest } = notifTextsForMessageInfo( allMessageInfos, threadInfo, ); if (!badgeOnly) { notification.body = merged; notification.sound = 'default'; } notification.payload = { ...notification.payload, ...rest, }; notification.badge = unreadCount; notification.threadId = threadInfo.id; notification.id = uniqueID; notification.pushType = 'alert'; notification.payload.id = uniqueID; notification.payload.threadID = threadInfo.id; notification.payload.messageInfos = JSON.stringify(newRawMessageInfos); if (collapseKey) { notification.collapseId = collapseKey; } return notification; } function prepareAndroidNotification( allMessageInfos: MessageInfo[], newRawMessageInfos: RawMessageInfo[], threadInfo: ThreadInfo, collapseKey: ?string, badgeOnly: boolean, unreadCount: number, dbID: string, codeVersion: number, ): Object { const notifID = collapseKey ? collapseKey : dbID; const { merged, ...rest } = notifTextsForMessageInfo( allMessageInfos, threadInfo, ); const messageInfos = JSON.stringify(newRawMessageInfos); if (badgeOnly && codeVersion < 69) { // Older Android clients don't look at badgeOnly, so if we sent them the // full payload they would treat it as a normal notif. Instead we will // send them this payload that is missing an ID, which will prevent the // system notif from being generated, but still allow for in-app notifs // and badge updating. return { data: { badge: unreadCount.toString(), ...rest, threadID: threadInfo.id, messageInfos, }, }; } else if (codeVersion < 31) { return { data: { badge: unreadCount.toString(), custom_notification: JSON.stringify({ channel: 'default', body: merged, badgeCount: unreadCount, id: notifID, priority: 'high', sound: 'default', icon: 'notif_icon', threadID: threadInfo.id, messageInfos, click_action: 'fcm.ACTION.HELLO', }), }, }; } return { data: { badge: unreadCount.toString(), ...rest, id: notifID, threadID: threadInfo.id, messageInfos, badgeOnly: badgeOnly ? '1' : '0', }, }; } type NotificationInfo = | {| +source: 'new_message', +dbID: string, +userID: string, +threadID: string, +messageID: string, +collapseKey: ?string, +codeVersion: number, |} | {| +source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', +dbID: string, +userID: string, +codeVersion: number, |}; type IOSDelivery = {| source: $PropertyType, deviceType: 'ios', iosID: string, deviceTokens: $ReadOnlyArray, codeVersion: number, errors?: $ReadOnlyArray, |}; type IOSResult = {| info: NotificationInfo, delivery: IOSDelivery, invalidTokens?: $ReadOnlyArray, |}; async function sendIOSNotification( notification: apn.Notification, deviceTokens: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const response = await apnPush(notification, deviceTokens); const delivery: IOSDelivery = { source: notificationInfo.source, deviceType: 'ios', iosID: notification.id, deviceTokens, codeVersion: notificationInfo.codeVersion, }; if (response.errors) { delivery.errors = response.errors; } const result: IOSResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type AndroidDelivery = {| source: $PropertyType, deviceType: 'android', androidIDs: $ReadOnlyArray, deviceTokens: $ReadOnlyArray, codeVersion: number, errors?: $ReadOnlyArray, |}; type AndroidResult = {| info: NotificationInfo, delivery: AndroidDelivery, invalidTokens?: $ReadOnlyArray, |}; async function sendAndroidNotification( notification: Object, deviceTokens: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const collapseKey = notificationInfo.collapseKey ? notificationInfo.collapseKey : null; // for Flow... const response = await fcmPush(notification, deviceTokens, collapseKey); const androidIDs = response.fcmIDs ? response.fcmIDs : []; const delivery: AndroidDelivery = { source: notificationInfo.source, deviceType: 'android', androidIDs, deviceTokens, codeVersion: notificationInfo.codeVersion, }; if (response.errors) { delivery.errors = response.errors; } const result: AndroidResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type InvalidToken = {| +userID: string, +tokens: $ReadOnlyArray, |}; async function removeInvalidTokens( invalidTokens: $ReadOnlyArray, ): Promise { const sqlTuples = invalidTokens.map( (invalidTokenUser) => SQL`( user = ${invalidTokenUser.userID} AND device_token IN (${invalidTokenUser.tokens}) )`, ); const sqlCondition = mergeOrConditions(sqlTuples); const selectQuery = SQL` SELECT id, user, device_token FROM cookies WHERE `; selectQuery.append(sqlCondition); const [result] = await dbQuery(selectQuery); const userCookiePairsToInvalidDeviceTokens = new Map(); for (let row of result) { const userCookiePair = `${row.user}|${row.id}`; const existing = userCookiePairsToInvalidDeviceTokens.get(userCookiePair); if (existing) { existing.add(row.device_token); } else { userCookiePairsToInvalidDeviceTokens.set( userCookiePair, new Set([row.device_token]), ); } } const time = Date.now(); const promises = []; for (let entry of userCookiePairsToInvalidDeviceTokens) { const [userCookiePair, deviceTokens] = entry; const [userID, cookieID] = userCookiePair.split('|'); const updateDatas = [...deviceTokens].map((deviceToken) => ({ type: updateTypes.BAD_DEVICE_TOKEN, userID, time, deviceToken, targetCookie: cookieID, })); promises.push(createUpdates(updateDatas)); } const updateQuery = SQL` UPDATE cookies SET device_token = NULL WHERE `; updateQuery.append(sqlCondition); promises.push(dbQuery(updateQuery)); await Promise.all(promises); } async function updateBadgeCount( viewer: Viewer, source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', excludeDeviceTokens: $ReadOnlyArray, ) { const { userID } = viewer; const deviceTokenQuery = SQL` SELECT platform, device_token, versions FROM cookies WHERE user = ${userID} AND device_token IS NOT NULL AND id != ${viewer.cookieID} `; if (excludeDeviceTokens.length > 0) { deviceTokenQuery.append( SQL`AND device_token NOT IN (${excludeDeviceTokens})`, ); } const [unreadCounts, [deviceTokenResult], [dbID]] = await Promise.all([ getUnreadCounts([userID]), dbQuery(deviceTokenQuery), createIDs('notifications', 1), ]); const unreadCount = unreadCounts[userID]; const devices = deviceTokenResult.map((row) => ({ deviceType: row.platform, deviceToken: row.device_token, codeVersion: row.versions?.codeVersion, })); const byDeviceType = getDevicesByDeviceType(devices); const deliveryPromises = []; const iosVersionsToTokens = byDeviceType.get('ios'); if (iosVersionsToTokens) { for (const [codeVer, deviceTokens] of iosVersionsToTokens) { const codeVersion = parseInt(codeVer, 10); // only for Flow const notification = new apn.Notification(); notification.topic = 'org.squadcal.app'; notification.badge = unreadCount; notification.pushType = 'alert'; deliveryPromises.push( sendIOSNotification(notification, [...deviceTokens], { source, dbID, userID, codeVersion, }), ); } } const androidVersionsToTokens = byDeviceType.get('android'); if (androidVersionsToTokens) { for (const [codeVer, deviceTokens] of androidVersionsToTokens) { const codeVersion = parseInt(codeVer, 10); // only for Flow const notification = { data: { badge: unreadCount.toString() } }; deliveryPromises.push( sendAndroidNotification(notification, [...deviceTokens], { source, dbID, userID, codeVersion, }), ); } } const deliveryResults = await Promise.all(deliveryPromises); await saveNotifResults(deliveryResults, new Map(), false); } export { sendPushNotifs, updateBadgeCount }; diff --git a/web/chat/message-preview.react.js b/web/chat/message-preview.react.js index 0b6b3625d..d7bdfda88 100644 --- a/web/chat/message-preview.react.js +++ b/web/chat/message-preview.react.js @@ -1,70 +1,74 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { messagePreviewText } from 'lib/shared/message-utils'; import { threadIsGroupChat } from 'lib/shared/thread-utils'; import { stringForUser } from 'lib/shared/user-utils'; import { type MessageInfo, messageInfoPropType, messageTypes, } from 'lib/types/message-types'; import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; import { firstLine } from 'lib/utils/string-utils'; import css from './chat-thread-list.css'; type Props = {| messageInfo: ?MessageInfo, threadInfo: ThreadInfo, |}; class MessagePreview extends React.PureComponent { static propTypes = { messageInfo: messageInfoPropType, threadInfo: threadInfoPropType.isRequired, }; render() { - const messageInfo = this.props.messageInfo; + const messageInfo = + this.props.messageInfo && + (this.props.messageInfo.type === messageTypes.SIDEBAR_SOURCE + ? this.props.messageInfo.initialMessage + : this.props.messageInfo); if (!messageInfo) { return (
No messages
); } const unread = this.props.threadInfo.currentUser.unread; if (messageInfo.type === messageTypes.TEXT) { let usernameText = null; if ( threadIsGroupChat(this.props.threadInfo) || this.props.threadInfo.name !== '' || messageInfo.creator.isViewer ) { const userString = stringForUser(messageInfo.creator); const username = `${userString}: `; const usernameStyle = unread ? css.black : css.light; usernameText = {username}; } const colorStyle = unread ? css.black : css.dark; return (
{usernameText} {firstLine(messageInfo.text)}
); } else { const preview = messagePreviewText(messageInfo, this.props.threadInfo); const colorStyle = unread ? css.black : css.light; return (
{firstLine(preview)}
); } } } export default MessagePreview;