diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index 5bcc6a988..03cb22a11 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,528 +1,531 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy'; import _orderBy from 'lodash/fp/orderBy'; import { useStringForUser } from '../hooks/ens-cache'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors'; import type { PlatformDetails } from '../types/device-types'; import type { Media } from '../types/media-types'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageTruncationStatus, type MultimediaMessageData, type MessageStore, type ComposableMessageInfo, messageTypes, messageTruncationStatus, type RawComposableMessageInfo, type ThreadMessageInfo, } from '../types/message-types'; import type { ImagesMessageData } from '../types/messages/images'; import type { MediaMessageData } from '../types/messages/media'; import type { RawReactionMessageInfo, ReactionMessageInfo, } from '../types/messages/reaction'; import { type ThreadInfo } from '../types/thread-types'; import type { UserInfos } from '../types/user-types'; -import type { EntityText } from '../utils/entity-text'; +import { type EntityText, entityTextToRawString } from '../utils/entity-text'; import { codeBlockRegex, type ParserRules } from './markdown'; import { messageSpecs } from './messages/message-specs'; import { threadIsGroupChat } from './thread-utils'; const localIDPrefix = 'local'; // 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 robotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ?ThreadInfo, ): EntityText { const messageSpec = messageSpecs[messageInfo.type]; invariant( messageSpec.robotext, `we're not aware of messageType ${messageInfo.type}`, ); return messageSpec.robotext(messageInfo, { threadInfo }); } function createMessageInfo( rawMessageInfo: RawMessageInfo, viewerID: ?string, userInfos: UserInfos, threadInfos: { +[id: string]: ThreadInfo }, ): ?MessageInfo { const creatorInfo = userInfos[rawMessageInfo.creatorID]; const creator = { id: rawMessageInfo.creatorID, username: creatorInfo ? creatorInfo.username : 'anonymous', isViewer: rawMessageInfo.creatorID === viewerID, }; const createRelativeUserInfos = (userIDs: $ReadOnlyArray) => userIDsToRelativeUserInfos(userIDs, viewerID, userInfos); const createMessageInfoFromRaw = (rawInfo: RawMessageInfo) => createMessageInfo(rawInfo, viewerID, userInfos, threadInfos); const messageSpec = messageSpecs[rawMessageInfo.type]; return messageSpec.createMessageInfo(rawMessageInfo, creator, { threadInfos, createMessageInfoFromRaw, createRelativeUserInfos, }); } type LengthResult = { +local: number, +realized: number, }; function findMessageIDMaxLengths( messageIDs: $ReadOnlyArray, ): LengthResult { const result = { local: 0, realized: 0, }; for (const id of messageIDs) { if (!id) { continue; } if (id.startsWith(localIDPrefix)) { result.local = Math.max(result.local, id.length - localIDPrefix.length); } else { result.realized = Math.max(result.realized, id.length); } } return result; } function extendMessageID(id: ?string, lengths: LengthResult): ?string { if (!id) { return id; } if (id.startsWith(localIDPrefix)) { const zeroPaddedID = id .substr(localIDPrefix.length) .padStart(lengths.local, '0'); return `${localIDPrefix}${zeroPaddedID}`; } return id.padStart(lengths.realized, '0'); } function sortMessageInfoList( messageInfos: $ReadOnlyArray, ): T[] { const lengths = findMessageIDMaxLengths( messageInfos.map(message => message?.id), ); return _orderBy([ 'time', (message: T) => extendMessageID(message?.id, lengths), ])(['desc', 'desc'])(messageInfos); } const sortMessageIDs: (messages: { +[id: string]: RawMessageInfo }) => ( messageIDs: $ReadOnlyArray, ) => string[] = messages => messageIDs => { const lengths = findMessageIDMaxLengths(messageIDs); return _orderBy([ (id: string) => messages[id].time, (id: string) => extendMessageID(id, lengths), ])(['desc', 'desc'])(messageIDs); }; function rawMessageInfoFromMessageData( messageData: MessageData, id: ?string, ): RawMessageInfo { const messageSpec = messageSpecs[messageData.type]; invariant( messageSpec.rawMessageInfoFromMessageData, `we're not aware of messageType ${messageData.type}`, ); return messageSpec.rawMessageInfoFromMessageData(messageData, id); } function mostRecentMessageTimestamp( messageInfos: $ReadOnlyArray, previousTimestamp: number, ): number { if (messageInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(messageInfos).time; } function usersInMessageInfos( messageInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (const 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 => { const { shimUnsupportedMessageInfo } = messageSpecs[rawMessageInfo.type]; if (shimUnsupportedMessageInfo) { return shimUnsupportedMessageInfo(rawMessageInfo, platformDetails); } return rawMessageInfo; }); } type MediaMessageDataCreationInput = $ReadOnly<{ threadID: string, creatorID: string, media: $ReadOnlyArray, localID?: ?string, time?: ?number, ... }>; function createMediaMessageData( input: MediaMessageDataCreationInput, ): MultimediaMessageData { let allMediaArePhotos = true; const photoMedia = []; for (const 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); const createRawMessageInfo = messageSpecs[messageData.type].rawMessageInfoFromMessageData; invariant( createRawMessageInfo, 'multimedia message spec should have rawMessageInfoFromMessageData', ); const result = createRawMessageInfo(messageData, input.id); invariant( result.type === messageTypes.MULTIMEDIA || result.type === messageTypes.IMAGES, `media messageSpec returned MessageType ${result.type}`, ); return result; } function stripLocalID( rawMessageInfo: RawComposableMessageInfo | RawReactionMessageInfo, ) { const { localID, ...rest } = rawMessageInfo; return rest; } function stripLocalIDs( input: $ReadOnlyArray, ): RawMessageInfo[] { const output = []; for (const rawMessageInfo of input) { if (rawMessageInfo.localID) { invariant( rawMessageInfo.id, 'serverID should be set if localID is being stripped', ); output.push(stripLocalID(rawMessageInfo)); } else { output.push(rawMessageInfo); } } 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): string { message = message.replace(/^\n*/, ''); return codeBlockRegex.exec(message) ? message.trimEnd() : message.trim(); } function createMessageQuote(message: string): string { // add `>` to each line to include empty lines in the quote return message.replace(/^/gm, '> '); } function createMessageReply(message: string): string { return createMessageQuote(message) + '\n\n'; } function getMostRecentNonLocalMessageID( threadID: string, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadID]; return thread?.messageIDs.find(id => !id.startsWith(localIDPrefix)); } export type GetMessageTitleViewerContext = | 'global_viewer' | 'individual_viewer'; function getMessageTitle( messageInfo: | ComposableMessageInfo | RobotextMessageInfo | ReactionMessageInfo, threadInfo: ThreadInfo, markdownRules: ParserRules, viewerContext?: GetMessageTitleViewerContext = 'individual_viewer', ): string { + let entityText; const { messageTitle } = messageSpecs[messageInfo.type]; - return messageTitle({ - messageInfo, - threadInfo, - markdownRules, - viewerContext, + if (!messageTitle) { + invariant( + messageInfo.type !== messageTypes.TEXT && + messageInfo.type !== messageTypes.IMAGES && + messageInfo.type !== messageTypes.MULTIMEDIA && + messageInfo.type !== messageTypes.REACTION, + 'messageTitle can only be auto-generated for RobotextMessageInfo', + ); + entityText = robotextForMessageInfo(messageInfo, threadInfo); + } else { + entityText = messageTitle({ messageInfo, threadInfo, markdownRules }); + } + return entityTextToRawString(entityText, { + threadID: threadInfo.id, + ignoreViewer: viewerContext === 'global_viewer', }); } -function removeCreatorAsViewer(messageInfo: Info): Info { - return { - ...messageInfo, - creator: { ...messageInfo.creator, isViewer: false }, - }; -} - function mergeThreadMessageInfos( first: ThreadMessageInfo, second: ThreadMessageInfo, messages: { +[id: string]: RawMessageInfo }, ): ThreadMessageInfo { let firstPointer = 0; let secondPointer = 0; const mergedMessageIDs = []; let firstCandidate = first.messageIDs[firstPointer]; let secondCandidate = second.messageIDs[secondPointer]; while (firstCandidate !== undefined || secondCandidate !== undefined) { if (firstCandidate === undefined) { mergedMessageIDs.push(secondCandidate); secondPointer++; } else if (secondCandidate === undefined) { mergedMessageIDs.push(firstCandidate); firstPointer++; } else if (firstCandidate === secondCandidate) { mergedMessageIDs.push(firstCandidate); firstPointer++; secondPointer++; } else { const firstMessage = messages[firstCandidate]; const secondMessage = messages[secondCandidate]; invariant( firstMessage && secondMessage, 'message in messageIDs not present in MessageStore', ); if ( (firstMessage.id && secondMessage.id && firstMessage.id === secondMessage.id) || (firstMessage.localID && secondMessage.localID && firstMessage.localID === secondMessage.localID) ) { mergedMessageIDs.push(firstCandidate); firstPointer++; secondPointer++; } else if (firstMessage.time < secondMessage.time) { mergedMessageIDs.push(secondCandidate); secondPointer++; } else { mergedMessageIDs.push(firstCandidate); firstPointer++; } } firstCandidate = first.messageIDs[firstPointer]; secondCandidate = second.messageIDs[secondPointer]; } return { messageIDs: mergedMessageIDs, startReached: first.startReached && second.startReached, lastNavigatedTo: Math.max(first.lastNavigatedTo, second.lastNavigatedTo), lastPruned: Math.max(first.lastPruned, second.lastPruned), }; } type MessagePreviewPart = { +text: string, // unread has highest contrast, followed by primary, followed by secondary +style: 'unread' | 'primary' | 'secondary', }; type MessagePreviewResult = { +message: MessagePreviewPart, +username: ?MessagePreviewPart, }; function useMessagePreview( originalMessageInfo: ?MessageInfo, threadInfo: ThreadInfo, markdownRules: ParserRules, ): ?MessagePreviewResult { let messageInfo; if ( originalMessageInfo && originalMessageInfo.type === messageTypes.SIDEBAR_SOURCE ) { messageInfo = originalMessageInfo.sourceMessage; } else { messageInfo = originalMessageInfo; } const hasUsername = threadIsGroupChat(threadInfo) || threadInfo.name !== '' || messageInfo?.creator.isViewer; const stringForUser = useStringForUser( messageInfo?.type === messageTypes.TEXT && hasUsername ? messageInfo?.creator : null, ); if (!messageInfo) { return messageInfo; } let username = null; if (messageInfo.type === messageTypes.TEXT && hasUsername) { invariant( stringForUser, 'useStringForUser should only return falsey if pass null or undefined', ); username = { text: stringForUser, style: 'secondary', }; } const messageTitle = getMessageTitle(messageInfo, threadInfo, markdownRules); const message = { text: messageTitle, style: messageInfo.type === messageTypes.TEXT ? 'primary' : 'secondary', }; if (threadInfo.currentUser.unread) { message.style = 'unread'; if (username) { username.style = 'unread'; } } return { message, username }; } export { localIDPrefix, messageKey, messageID, robotextForMessageInfo, createMessageInfo, sortMessageInfoList, sortMessageIDs, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, usersInMessageInfos, combineTruncationStatuses, shimUnsupportedRawMessageInfos, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageQuote, createMessageReply, getMostRecentNonLocalMessageID, getMessageTitle, - removeCreatorAsViewer, mergeThreadMessageInfos, useMessagePreview, }; diff --git a/lib/shared/messages/add-members-message-spec.js b/lib/shared/messages/add-members-message-spec.js index 2d2f191a0..083bc0069 100644 --- a/lib/shared/messages/add-members-message-spec.js +++ b/lib/shared/messages/add-members-message-spec.js @@ -1,190 +1,163 @@ // @flow import invariant from 'invariant'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { AddMembersMessageData, AddMembersMessageInfo, RawAddMembersMessageInfo, } from '../../types/messages/add-members'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; import { ET, type EntityText, pluralizeEntityText, - entityTextToRawString, } from '../../utils/entity-text'; import { values } from '../../utils/objects'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; import type { CreateMessageInfoParams, MessageSpec, - MessageTitleParam, NotificationTextsParams, } from './message-spec'; import { joinResult } from './utils'; export const addMembersMessageSpec: MessageSpec< AddMembersMessageData, RawAddMembersMessageInfo, AddMembersMessageInfo, > = Object.freeze({ messageContentForServerDB( data: AddMembersMessageData | RawAddMembersMessageInfo, ): string { return JSON.stringify(data.addedUserIDs); }, messageContentForClientDB(data: RawAddMembersMessageInfo): string { return this.messageContentForServerDB(data); }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: AddMembersMessageInfo = (messageInfo: AddMembersMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - validMessageInfo = { - ...validMessageInfo, - addedMembers: validMessageInfo.addedMembers.map(item => ({ - ...item, - isViewer: false, - })), - }; - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - rawMessageInfoFromServerDBRow(row: Object): RawAddMembersMessageInfo { 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), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawAddMembersMessageInfo { const content = clientDBMessageInfo.content; invariant( content !== undefined && content !== null, 'content must be defined', ); const rawAddMembersMessageInfo: RawAddMembersMessageInfo = { type: messageTypes.ADD_MEMBERS, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, addedUserIDs: JSON.parse(content), }; return rawAddMembersMessageInfo; }, createMessageInfo( rawMessageInfo: RawAddMembersMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): AddMembersMessageInfo { const addedMembers = params.createRelativeUserInfos( rawMessageInfo.addedUserIDs, ); return { type: messageTypes.ADD_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, addedMembers, }; }, rawMessageInfoFromMessageData( messageData: AddMembersMessageData, id: ?string, ): RawAddMembersMessageInfo { invariant(id, 'RawAddMembersMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: AddMembersMessageInfo): EntityText { const users = messageInfo.addedMembers; invariant(users.length !== 0, 'added who??'); const creator = ET.user({ userInfo: messageInfo.creator }); const addedUsers = pluralizeEntityText( users.map(user => ET`${ET.user({ userInfo: user })}`), ); return ET`${creator} added ${addedUsers}`; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { const addedMembersObject = {}; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.ADD_MEMBERS, 'messageInfo should be messageTypes.ADD_MEMBERS!', ); for (const 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 = params.strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} to ${params.notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; }, notificationCollapseKey(rawMessageInfo: RawAddMembersMessageInfo): string { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); }, generatesNotifs: async () => undefined, userIDs(rawMessageInfo: RawAddMembersMessageInfo): $ReadOnlyArray { return rawMessageInfo.addedUserIDs; }, }); diff --git a/lib/shared/messages/change-role-message-spec.js b/lib/shared/messages/change-role-message-spec.js index c5e0f1ac0..a81ceb1e2 100644 --- a/lib/shared/messages/change-role-message-spec.js +++ b/lib/shared/messages/change-role-message-spec.js @@ -1,205 +1,178 @@ // @flow import invariant from 'invariant'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { ChangeRoleMessageData, ChangeRoleMessageInfo, RawChangeRoleMessageInfo, } from '../../types/messages/change-role'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; import { ET, type EntityText, pluralizeEntityText, - entityTextToRawString, } from '../../utils/entity-text'; import { values } from '../../utils/objects'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, - type MessageTitleParam, type NotificationTextsParams, type RobotextParams, } from './message-spec'; import { joinResult } from './utils'; export const changeRoleMessageSpec: MessageSpec< ChangeRoleMessageData, RawChangeRoleMessageInfo, ChangeRoleMessageInfo, > = Object.freeze({ messageContentForServerDB( data: ChangeRoleMessageData | RawChangeRoleMessageInfo, ): string { return JSON.stringify({ userIDs: data.userIDs, newRole: data.newRole, }); }, messageContentForClientDB(data: RawChangeRoleMessageInfo): string { return this.messageContentForServerDB(data); }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: ChangeRoleMessageInfo = (messageInfo: ChangeRoleMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - validMessageInfo = { - ...validMessageInfo, - members: validMessageInfo.members.map(item => ({ - ...item, - isViewer: false, - })), - }; - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - rawMessageInfoFromServerDBRow(row: Object): RawChangeRoleMessageInfo { 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, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawChangeRoleMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined', ); const content = JSON.parse(clientDBMessageInfo.content); const rawChangeRoleMessageInfo: RawChangeRoleMessageInfo = { type: messageTypes.CHANGE_ROLE, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, userIDs: content.userIDs, newRole: content.newRole, }; return rawChangeRoleMessageInfo; }, createMessageInfo( rawMessageInfo: RawChangeRoleMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ChangeRoleMessageInfo { const members = params.createRelativeUserInfos(rawMessageInfo.userIDs); return { type: messageTypes.CHANGE_ROLE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, members, newRole: rawMessageInfo.newRole, }; }, rawMessageInfoFromMessageData( messageData: ChangeRoleMessageData, id: ?string, ): RawChangeRoleMessageInfo { invariant(id, 'RawChangeRoleMessageInfo needs id'); return { ...messageData, id }; }, robotext( messageInfo: ChangeRoleMessageInfo, params: RobotextParams, ): EntityText { const users = messageInfo.members; invariant(users.length !== 0, 'changed whose role??'); const creator = ET.user({ userInfo: messageInfo.creator }); const affectedUsers = pluralizeEntityText( users.map(user => ET`${ET.user({ userInfo: user })}`), ); const { threadInfo } = params; invariant(threadInfo, 'ThreadInfo should be set for CHANGE_ROLE message'); const verb = threadInfo.roles[messageInfo.newRole].isDefault ? 'removed' : 'added'; const noun = users.length === 1 ? 'an admin' : 'admins'; return ET`${creator} ${verb} ${affectedUsers} as ${noun}`; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { const membersObject = {}; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.CHANGE_ROLE, 'messageInfo should be messageTypes.CHANGE_ROLE!', ); for (const 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 = params.strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} from ${params.notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; }, notificationCollapseKey(rawMessageInfo: RawChangeRoleMessageInfo): string { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.newRole, ); }, generatesNotifs: async () => pushTypes.NOTIF, }); diff --git a/lib/shared/messages/change-settings-message-spec.js b/lib/shared/messages/change-settings-message-spec.js index 25e552411..8eb6d748b 100644 --- a/lib/shared/messages/change-settings-message-spec.js +++ b/lib/shared/messages/change-settings-message-spec.js @@ -1,196 +1,173 @@ // @flow import invariant from 'invariant'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { ChangeSettingsMessageData, ChangeSettingsMessageInfo, RawChangeSettingsMessageInfo, } from '../../types/messages/change-settings'; import type { NotifTexts } from '../../types/notif-types'; import { assertThreadType } from '../../types/thread-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; -import { - ET, - type EntityText, - entityTextToRawString, -} from '../../utils/entity-text'; +import { ET, type EntityText } from '../../utils/entity-text'; import { validHexColorRegex } from '../account-utils'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; import { threadLabel } from '../thread-utils'; import { pushTypes, type MessageSpec, - type MessageTitleParam, type NotificationTextsParams, } from './message-spec'; import { joinResult } from './utils'; export const changeSettingsMessageSpec: MessageSpec< ChangeSettingsMessageData, RawChangeSettingsMessageInfo, ChangeSettingsMessageInfo, > = Object.freeze({ messageContentForServerDB( data: ChangeSettingsMessageData | RawChangeSettingsMessageInfo, ): string { return JSON.stringify({ [data.field]: data.value, }); }, messageContentForClientDB(data: RawChangeSettingsMessageInfo): string { return this.messageContentForServerDB(data); }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: ChangeSettingsMessageInfo = (messageInfo: ChangeSettingsMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - rawMessageInfoFromServerDBRow(row: Object): RawChangeSettingsMessageInfo { 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], }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawChangeSettingsMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined', ); const content = JSON.parse(clientDBMessageInfo.content); const field = Object.keys(content)[0]; const rawChangeSettingsMessageInfo: RawChangeSettingsMessageInfo = { type: messageTypes.CHANGE_SETTINGS, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, field, value: content[field], }; return rawChangeSettingsMessageInfo; }, createMessageInfo( rawMessageInfo: RawChangeSettingsMessageInfo, creator: RelativeUserInfo, ): ChangeSettingsMessageInfo { return { type: messageTypes.CHANGE_SETTINGS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, field: rawMessageInfo.field, value: rawMessageInfo.value, }; }, rawMessageInfoFromMessageData( messageData: ChangeSettingsMessageData, id: ?string, ): RawChangeSettingsMessageInfo { invariant(id, 'RawChangeSettingsMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: ChangeSettingsMessageInfo): EntityText { const creator = ET.user({ userInfo: messageInfo.creator }); if ( (messageInfo.field === 'name' || messageInfo.field === 'description') && messageInfo.value.toString() === '' ) { return ET`${creator} cleared ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, possessive: true, })} ${messageInfo.field}`; } let value; if ( messageInfo.field === 'color' && messageInfo.value.toString().match(validHexColorRegex) ) { value = ET.color({ hex: `#${messageInfo.value}` }); } else if (messageInfo.field === 'type') { invariant( typeof messageInfo.value === 'number', 'messageInfo.value should be number for thread type change ', ); const newThreadType = assertThreadType(messageInfo.value); value = threadLabel(newThreadType); } else { value = messageInfo.value.toString(); } return ET`${creator} updated ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, possessive: true, })} ${messageInfo.field} to "${value}"`; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.CHANGE_SETTINGS, 'messageInfo should be messageTypes.CHANGE_SETTINGS!', ); const body = params.strippedRobotextForMessageInfo( mostRecentMessageInfo, threadInfo, ); return { merged: body, title: threadInfo.uiName, body, }; }, notificationCollapseKey( rawMessageInfo: RawChangeSettingsMessageInfo, ): string { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.field, ); }, generatesNotifs: async () => pushTypes.NOTIF, }); diff --git a/lib/shared/messages/create-entry-message-spec.js b/lib/shared/messages/create-entry-message-spec.js index e189e1fd1..f06fa4d10 100644 --- a/lib/shared/messages/create-entry-message-spec.js +++ b/lib/shared/messages/create-entry-message-spec.js @@ -1,187 +1,164 @@ // @flow import invariant from 'invariant'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { CreateEntryMessageData, CreateEntryMessageInfo, RawCreateEntryMessageInfo, } from '../../types/messages/create-entry'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; import { prettyDate } from '../../utils/date-utils'; -import { - ET, - type EntityText, - entityTextToRawString, -} from '../../utils/entity-text'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; +import { ET, type EntityText } from '../../utils/entity-text'; import { stringForUser } from '../user-utils'; import { pushTypes, type MessageSpec, - type MessageTitleParam, type NotificationTextsParams, } from './message-spec'; import { joinResult } from './utils'; export const createEntryMessageSpec: MessageSpec< CreateEntryMessageData, RawCreateEntryMessageInfo, CreateEntryMessageInfo, > = Object.freeze({ messageContentForServerDB( data: CreateEntryMessageData | RawCreateEntryMessageInfo, ): string { return JSON.stringify({ entryID: data.entryID, date: data.date, text: data.text, }); }, messageContentForClientDB(data: RawCreateEntryMessageInfo): string { return this.messageContentForServerDB(data); }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: CreateEntryMessageInfo = (messageInfo: CreateEntryMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - rawMessageInfoFromServerDBRow(row: Object): RawCreateEntryMessageInfo { 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, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawCreateEntryMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined', ); const content = JSON.parse(clientDBMessageInfo.content); const rawCreateEntryMessageInfo: RawCreateEntryMessageInfo = { type: messageTypes.CREATE_ENTRY, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, entryID: content.entryID, date: content.date, text: content.text, }; return rawCreateEntryMessageInfo; }, createMessageInfo( rawMessageInfo: RawCreateEntryMessageInfo, creator: RelativeUserInfo, ): CreateEntryMessageInfo { return { type: messageTypes.CREATE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; }, rawMessageInfoFromMessageData( messageData: CreateEntryMessageData, id: ?string, ): RawCreateEntryMessageInfo { invariant(id, 'RawCreateEntryMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: CreateEntryMessageInfo): EntityText { const date = prettyDate(messageInfo.date); const creator = ET.user({ userInfo: messageInfo.creator }); const { text } = messageInfo; return ET`${creator} created an event scheduled for ${date}: "${text}"`; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { 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 ` + `${params.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 ${params.notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; }, notificationCollapseKey(rawMessageInfo: RawCreateEntryMessageInfo): string { return joinResult(rawMessageInfo.creatorID, rawMessageInfo.entryID); }, generatesNotifs: async () => pushTypes.NOTIF, }); diff --git a/lib/shared/messages/create-sidebar-message-spec.js b/lib/shared/messages/create-sidebar-message-spec.js index fad7c8c7b..9c2e81dee 100644 --- a/lib/shared/messages/create-sidebar-message-spec.js +++ b/lib/shared/messages/create-sidebar-message-spec.js @@ -1,255 +1,219 @@ // @flow import invariant from 'invariant'; import type { PlatformDetails } from '../../types/device-types'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { CreateSidebarMessageData, CreateSidebarMessageInfo, RawCreateSidebarMessageInfo, } from '../../types/messages/create-sidebar'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; import { ET, type EntityText, pluralizeEntityText, - entityTextToRawString, } from '../../utils/entity-text'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; import { stringForUser } from '../user-utils'; import { hasMinCodeVersion } from '../version-utils'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, - type MessageTitleParam, } from './message-spec'; import { assertSingleMessageInfo } from './utils'; export const createSidebarMessageSpec: MessageSpec< CreateSidebarMessageData, RawCreateSidebarMessageInfo, CreateSidebarMessageInfo, > = Object.freeze({ messageContentForServerDB( data: CreateSidebarMessageData | RawCreateSidebarMessageInfo, ): string { return JSON.stringify({ ...data.initialThreadState, sourceMessageAuthorID: data.sourceMessageAuthorID, }); }, messageContentForClientDB(data: RawCreateSidebarMessageInfo): string { return this.messageContentForServerDB(data); }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: CreateSidebarMessageInfo = (messageInfo: CreateSidebarMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - validMessageInfo = { - ...validMessageInfo, - sourceMessageAuthor: { - ...validMessageInfo.sourceMessageAuthor, - isViewer: false, - }, - initialThreadState: { - ...validMessageInfo.initialThreadState, - otherMembers: validMessageInfo.initialThreadState.otherMembers.map( - item => ({ - ...item, - isViewer: false, - }), - ), - }, - }; - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - rawMessageInfoFromServerDBRow(row: Object): RawCreateSidebarMessageInfo { const { sourceMessageAuthorID, ...initialThreadState } = JSON.parse( row.content, ); return { type: messageTypes.CREATE_SIDEBAR, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), sourceMessageAuthorID, initialThreadState, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawCreateSidebarMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined', ); const { sourceMessageAuthorID, ...initialThreadState } = JSON.parse( clientDBMessageInfo.content, ); const rawCreateSidebarMessageInfo: RawCreateSidebarMessageInfo = { type: messageTypes.CREATE_SIDEBAR, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, sourceMessageAuthorID: sourceMessageAuthorID, initialThreadState: initialThreadState, }; return rawCreateSidebarMessageInfo; }, createMessageInfo( rawMessageInfo: RawCreateSidebarMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ?CreateSidebarMessageInfo { const { threadInfos } = params; const parentThreadInfo = threadInfos[rawMessageInfo.initialThreadState.parentThreadID]; const sourceMessageAuthor = params.createRelativeUserInfos([ rawMessageInfo.sourceMessageAuthorID, ])[0]; if (!sourceMessageAuthor) { return null; } return { type: messageTypes.CREATE_SIDEBAR, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, sourceMessageAuthor, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, color: rawMessageInfo.initialThreadState.color, otherMembers: params.createRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), ), }, }; }, rawMessageInfoFromMessageData( messageData: CreateSidebarMessageData, id: ?string, ): RawCreateSidebarMessageInfo { invariant(id, 'RawCreateSidebarMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: CreateSidebarMessageInfo): EntityText { let text = ET`started ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, })}`; const users = messageInfo.initialThreadState.otherMembers.filter( member => member.id !== messageInfo.sourceMessageAuthor.id, ); if (users.length !== 0) { const initialUsers = pluralizeEntityText( users.map(user => ET`${ET.user({ userInfo: user })}`), ); text = ET`${text} and added ${initialUsers}`; } const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} ${text}`; }, shimUnsupportedMessageInfo( rawMessageInfo: RawCreateSidebarMessageInfo, platformDetails: ?PlatformDetails, ): RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo { // 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 thread', unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo( unwrapped: RawCreateSidebarMessageInfo, ): RawCreateSidebarMessageInfo { return unwrapped; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): NotifTexts { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_SIDEBAR, 'messageInfo should be messageTypes.CREATE_SIDEBAR!', ); const prefix = stringForUser(messageInfo.creator); const title = threadInfo.uiName; const sourceMessageAuthorPossessive = messageInfo.sourceMessageAuthor .isViewer ? 'your' : `${stringForUser(messageInfo.sourceMessageAuthor)}'s`; const body = `started a thread in response to ${sourceMessageAuthorPossessive} ` + `message "${messageInfo.initialThreadState.name ?? ''}"`; const merged = `${prefix} ${body}`; return { merged, body, title, prefix, }; }, generatesNotifs: async () => pushTypes.NOTIF, userIDs(rawMessageInfo: RawCreateSidebarMessageInfo): $ReadOnlyArray { return rawMessageInfo.initialThreadState.memberIDs; }, threadIDs( rawMessageInfo: RawCreateSidebarMessageInfo, ): $ReadOnlyArray { const { parentThreadID } = rawMessageInfo.initialThreadState; return [parentThreadID]; }, }); diff --git a/lib/shared/messages/create-sub-thread-message-spec.js b/lib/shared/messages/create-sub-thread-message-spec.js index 83bd9702d..6df5a072c 100644 --- a/lib/shared/messages/create-sub-thread-message-spec.js +++ b/lib/shared/messages/create-sub-thread-message-spec.js @@ -1,186 +1,163 @@ // @flow import invariant from 'invariant'; import { permissionLookup } from '../../permissions/thread-permissions'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { CreateSubthreadMessageData, CreateSubthreadMessageInfo, RawCreateSubthreadMessageInfo, } from '../../types/messages/create-subthread'; import type { NotifTexts } from '../../types/notif-types'; import { threadPermissions, threadTypes } from '../../types/thread-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; -import { - ET, - type EntityText, - entityTextToRawString, -} from '../../utils/entity-text'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; +import { ET, type EntityText } from '../../utils/entity-text'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, - type MessageTitleParam, type NotificationTextsParams, type GeneratesNotifsParams, } from './message-spec'; import { assertSingleMessageInfo } from './utils'; export const createSubThreadMessageSpec: MessageSpec< CreateSubthreadMessageData, RawCreateSubthreadMessageInfo, CreateSubthreadMessageInfo, > = Object.freeze({ messageContentForServerDB( data: CreateSubthreadMessageData | RawCreateSubthreadMessageInfo, ): string { return data.childThreadID; }, messageContentForClientDB(data: RawCreateSubthreadMessageInfo): string { return this.messageContentForServerDB(data); }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: CreateSubthreadMessageInfo = (messageInfo: CreateSubthreadMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - rawMessageInfoFromServerDBRow(row: Object): ?RawCreateSubthreadMessageInfo { 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, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawCreateSubthreadMessageInfo { const content = clientDBMessageInfo.content; invariant( content !== undefined && content !== null, 'content must be defined', ); const rawCreateSubthreadMessageInfo: RawCreateSubthreadMessageInfo = { type: messageTypes.CREATE_SUB_THREAD, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, childThreadID: content, }; return rawCreateSubthreadMessageInfo; }, createMessageInfo( rawMessageInfo: RawCreateSubthreadMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ?CreateSubthreadMessageInfo { const { threadInfos } = params; const childThreadInfo = threadInfos[rawMessageInfo.childThreadID]; if (!childThreadInfo) { return null; } return { type: messageTypes.CREATE_SUB_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, childThreadInfo, }; }, rawMessageInfoFromMessageData( messageData: CreateSubthreadMessageData, id: ?string, ): RawCreateSubthreadMessageInfo { invariant(id, 'RawCreateSubthreadMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: CreateSubthreadMessageInfo): EntityText { const threadEntity = ET.thread({ display: 'shortName', threadInfo: messageInfo.childThreadInfo, subchannel: true, }); let text; if (messageInfo.childThreadInfo.name) { const childNoun = messageInfo.childThreadInfo.type === threadTypes.SIDEBAR ? 'thread' : 'subchannel'; text = ET`created a ${childNoun} named "${threadEntity}"`; } else { text = ET`created a ${threadEntity}`; } const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} ${text}`; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_SUB_THREAD, 'messageInfo should be messageTypes.CREATE_SUB_THREAD!', ); return params.notifTextForSubthreadCreation( messageInfo.creator, messageInfo.childThreadInfo.type, threadInfo, messageInfo.childThreadInfo.name, messageInfo.childThreadInfo.uiName, ); }, generatesNotifs: async ( rawMessageInfo: RawCreateSubthreadMessageInfo, params: GeneratesNotifsParams, ) => { const { userNotMemberOfSubthreads } = params; return userNotMemberOfSubthreads.has(rawMessageInfo.childThreadID) ? pushTypes.NOTIF : undefined; }, threadIDs( rawMessageInfo: RawCreateSubthreadMessageInfo, ): $ReadOnlyArray { return [rawMessageInfo.childThreadID]; }, }); diff --git a/lib/shared/messages/create-thread-message-spec.js b/lib/shared/messages/create-thread-message-spec.js index a577958cb..eba581ba9 100644 --- a/lib/shared/messages/create-thread-message-spec.js +++ b/lib/shared/messages/create-thread-message-spec.js @@ -1,222 +1,190 @@ // @flow import invariant from 'invariant'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { CreateThreadMessageData, CreateThreadMessageInfo, RawCreateThreadMessageInfo, } from '../../types/messages/create-thread'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; import { ET, type EntityText, pluralizeEntityText, - entityTextToRawString, } from '../../utils/entity-text'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; import { stringForUser } from '../user-utils'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, - type MessageTitleParam, type NotificationTextsParams, } from './message-spec'; import { assertSingleMessageInfo } from './utils'; export const createThreadMessageSpec: MessageSpec< CreateThreadMessageData, RawCreateThreadMessageInfo, CreateThreadMessageInfo, > = Object.freeze({ messageContentForServerDB( data: CreateThreadMessageData | RawCreateThreadMessageInfo, ): string { return JSON.stringify(data.initialThreadState); }, messageContentForClientDB(data: RawCreateThreadMessageInfo): string { return this.messageContentForServerDB(data); }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: CreateThreadMessageInfo = (messageInfo: CreateThreadMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - validMessageInfo = { - ...validMessageInfo, - initialThreadState: { - ...validMessageInfo.initialThreadState, - otherMembers: validMessageInfo.initialThreadState.otherMembers.map( - item => ({ - ...item, - isViewer: false, - }), - ), - }, - }; - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - rawMessageInfoFromServerDBRow(row: Object): RawCreateThreadMessageInfo { 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), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawCreateThreadMessageInfo { const content = clientDBMessageInfo.content; invariant( content !== undefined && content !== null, 'content must be defined', ); const rawCreateThreadMessageInfo: RawCreateThreadMessageInfo = { type: messageTypes.CREATE_THREAD, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, initialThreadState: JSON.parse(content), }; return rawCreateThreadMessageInfo; }, createMessageInfo( rawMessageInfo: RawCreateThreadMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): CreateThreadMessageInfo { const initialParentThreadID = rawMessageInfo.initialThreadState.parentThreadID; const parentThreadInfo = initialParentThreadID ? params.threadInfos[initialParentThreadID] : null; return { type: messageTypes.CREATE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, type: rawMessageInfo.initialThreadState.type, color: rawMessageInfo.initialThreadState.color, otherMembers: params.createRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), ), }, }; }, rawMessageInfoFromMessageData( messageData: CreateThreadMessageData, id: ?string, ): RawCreateThreadMessageInfo { invariant(id, 'RawCreateThreadMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: CreateThreadMessageInfo): EntityText { let text = ET`created ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, })}`; const parentThread = messageInfo.initialThreadState.parentThreadInfo; if (parentThread) { text = ET`${text} as a child of ${ET.thread({ display: 'uiName', threadInfo: parentThread, })}`; } if (messageInfo.initialThreadState.name) { text = ET`${text} with the name "${messageInfo.initialThreadState.name}"`; } const users = messageInfo.initialThreadState.otherMembers; if (users.length !== 0) { const initialUsers = pluralizeEntityText( users.map(user => ET`${ET.user({ userInfo: user })}`), ); text = ET`${text} and added ${initialUsers}`; } const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} ${text}`; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_THREAD, 'messageInfo should be messageTypes.CREATE_THREAD!', ); const parentThreadInfo = messageInfo.initialThreadState.parentThreadInfo; if (parentThreadInfo) { return params.notifTextForSubthreadCreation( messageInfo.creator, messageInfo.initialThreadState.type, parentThreadInfo, messageInfo.initialThreadState.name, threadInfo.uiName, ); } const prefix = stringForUser(messageInfo.creator); const body = 'created a new chat'; let merged = `${prefix} ${body}`; if (messageInfo.initialThreadState.name) { merged += ` called "${messageInfo.initialThreadState.name}"`; } return { merged, body, title: threadInfo.uiName, prefix, }; }, generatesNotifs: async () => pushTypes.NOTIF, userIDs(rawMessageInfo: RawCreateThreadMessageInfo): $ReadOnlyArray { return rawMessageInfo.initialThreadState.memberIDs; }, startsThread: true, threadIDs( rawMessageInfo: RawCreateThreadMessageInfo, ): $ReadOnlyArray { const { parentThreadID } = rawMessageInfo.initialThreadState; return parentThreadID ? [parentThreadID] : []; }, }); diff --git a/lib/shared/messages/delete-entry-message-spec.js b/lib/shared/messages/delete-entry-message-spec.js index cfa3ef818..a9c531438 100644 --- a/lib/shared/messages/delete-entry-message-spec.js +++ b/lib/shared/messages/delete-entry-message-spec.js @@ -1,161 +1,138 @@ // @flow import invariant from 'invariant'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { DeleteEntryMessageData, DeleteEntryMessageInfo, RawDeleteEntryMessageInfo, } from '../../types/messages/delete-entry'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; import { prettyDate } from '../../utils/date-utils'; -import { - ET, - type EntityText, - entityTextToRawString, -} from '../../utils/entity-text'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; +import { ET, type EntityText } from '../../utils/entity-text'; import { stringForUser } from '../user-utils'; import { pushTypes, type MessageSpec, - type MessageTitleParam, type NotificationTextsParams, } from './message-spec'; import { assertSingleMessageInfo } from './utils'; export const deleteEntryMessageSpec: MessageSpec< DeleteEntryMessageData, RawDeleteEntryMessageInfo, DeleteEntryMessageInfo, > = Object.freeze({ messageContentForServerDB( data: DeleteEntryMessageData | RawDeleteEntryMessageInfo, ): string { return JSON.stringify({ entryID: data.entryID, date: data.date, text: data.text, }); }, messageContentForClientDB(data: RawDeleteEntryMessageInfo): string { return this.messageContentForServerDB(data); }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: DeleteEntryMessageInfo = (messageInfo: DeleteEntryMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - rawMessageInfoFromServerDBRow(row: Object): RawDeleteEntryMessageInfo { 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, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawDeleteEntryMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined', ); const content = JSON.parse(clientDBMessageInfo.content); const rawDeleteEntryMessageInfo: RawDeleteEntryMessageInfo = { type: messageTypes.DELETE_ENTRY, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, entryID: content.entryID, date: content.date, text: content.text, }; return rawDeleteEntryMessageInfo; }, createMessageInfo( rawMessageInfo: RawDeleteEntryMessageInfo, creator: RelativeUserInfo, ): DeleteEntryMessageInfo { return { type: messageTypes.DELETE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; }, rawMessageInfoFromMessageData( messageData: DeleteEntryMessageData, id: ?string, ): RawDeleteEntryMessageInfo { invariant(id, 'RawDeleteEntryMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: DeleteEntryMessageInfo): EntityText { const date = prettyDate(messageInfo.date); const creator = ET.user({ userInfo: messageInfo.creator }); const { text } = messageInfo; return ET`${creator} deleted an event scheduled for ${date}: "${text}"`; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { 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 ${params.notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; }, generatesNotifs: async () => pushTypes.NOTIF, }); diff --git a/lib/shared/messages/edit-entry-message-spec.js b/lib/shared/messages/edit-entry-message-spec.js index 7d33526ba..9f585fcdb 100644 --- a/lib/shared/messages/edit-entry-message-spec.js +++ b/lib/shared/messages/edit-entry-message-spec.js @@ -1,187 +1,164 @@ // @flow import invariant from 'invariant'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { EditEntryMessageData, EditEntryMessageInfo, RawEditEntryMessageInfo, } from '../../types/messages/edit-entry'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; import { prettyDate } from '../../utils/date-utils'; -import { - ET, - type EntityText, - entityTextToRawString, -} from '../../utils/entity-text'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; +import { ET, type EntityText } from '../../utils/entity-text'; import { stringForUser } from '../user-utils'; import { pushTypes, type MessageSpec, - type MessageTitleParam, type NotificationTextsParams, } from './message-spec'; import { joinResult } from './utils'; export const editEntryMessageSpec: MessageSpec< EditEntryMessageData, RawEditEntryMessageInfo, EditEntryMessageInfo, > = Object.freeze({ messageContentForServerDB( data: EditEntryMessageData | RawEditEntryMessageInfo, ): string { return JSON.stringify({ entryID: data.entryID, date: data.date, text: data.text, }); }, messageContentForClientDB(data: RawEditEntryMessageInfo): string { return this.messageContentForServerDB(data); }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: EditEntryMessageInfo = (messageInfo: EditEntryMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - rawMessageInfoFromServerDBRow(row: Object): RawEditEntryMessageInfo { 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, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawEditEntryMessageInfo { invariant( clientDBMessageInfo.content !== null && clientDBMessageInfo.content !== undefined, 'content must be defined', ); const content = JSON.parse(clientDBMessageInfo.content); const rawEditEntryMessageInfo: RawEditEntryMessageInfo = { type: messageTypes.EDIT_ENTRY, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, entryID: content.entryID, date: content.date, text: content.text, }; return rawEditEntryMessageInfo; }, createMessageInfo( rawMessageInfo: RawEditEntryMessageInfo, creator: RelativeUserInfo, ): EditEntryMessageInfo { return { type: messageTypes.EDIT_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; }, rawMessageInfoFromMessageData( messageData: EditEntryMessageData, id: ?string, ): RawEditEntryMessageInfo { invariant(id, 'RawEditEntryMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: EditEntryMessageInfo): EntityText { const date = prettyDate(messageInfo.date); const creator = ET.user({ userInfo: messageInfo.creator }); const { text } = messageInfo; return ET`${creator} updated the text of an event scheduled for ${date}: "${text}"`; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { 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 ` + `${params.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 ${params.notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; }, notificationCollapseKey(rawMessageInfo: RawEditEntryMessageInfo): string { return joinResult(rawMessageInfo.creatorID, rawMessageInfo.entryID); }, generatesNotifs: async () => pushTypes.NOTIF, }); diff --git a/lib/shared/messages/join-thread-message-spec.js b/lib/shared/messages/join-thread-message-spec.js index 2696997fe..280d43832 100644 --- a/lib/shared/messages/join-thread-message-spec.js +++ b/lib/shared/messages/join-thread-message-spec.js @@ -1,138 +1,112 @@ // @flow import invariant from 'invariant'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { JoinThreadMessageData, JoinThreadMessageInfo, RawJoinThreadMessageInfo, } from '../../types/messages/join-thread'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; -import { - ET, - type EntityText, - entityTextToRawString, -} from '../../utils/entity-text'; +import { ET, type EntityText } from '../../utils/entity-text'; import { values } from '../../utils/objects'; import { pluralize } from '../../utils/text-utils'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; import { stringForUser } from '../user-utils'; -import type { - MessageSpec, - MessageTitleParam, - NotificationTextsParams, -} from './message-spec'; +import type { MessageSpec, NotificationTextsParams } from './message-spec'; import { joinResult } from './utils'; export const joinThreadMessageSpec: MessageSpec< JoinThreadMessageData, RawJoinThreadMessageInfo, JoinThreadMessageInfo, > = Object.freeze({ rawMessageInfoFromServerDBRow(row: Object): RawJoinThreadMessageInfo { return { type: messageTypes.JOIN_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawJoinThreadMessageInfo { const rawJoinThreadMessageInfo: RawJoinThreadMessageInfo = { type: messageTypes.JOIN_THREAD, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, }; return rawJoinThreadMessageInfo; }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: JoinThreadMessageInfo = (messageInfo: JoinThreadMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - createMessageInfo( rawMessageInfo: RawJoinThreadMessageInfo, creator: RelativeUserInfo, ): JoinThreadMessageInfo { return { type: messageTypes.JOIN_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, }; }, rawMessageInfoFromMessageData( messageData: JoinThreadMessageData, id: ?string, ): RawJoinThreadMessageInfo { invariant(id, 'RawJoinThreadMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: JoinThreadMessageInfo): EntityText { const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} joined ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, })}`; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { const joinerArray = {}; for (const 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} ${params.notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body, }; }, notificationCollapseKey(rawMessageInfo: RawJoinThreadMessageInfo): string { return joinResult(rawMessageInfo.type, rawMessageInfo.threadID); }, generatesNotifs: async () => undefined, }); diff --git a/lib/shared/messages/leave-thread-message-spec.js b/lib/shared/messages/leave-thread-message-spec.js index 575be8bac..f04d1d46a 100644 --- a/lib/shared/messages/leave-thread-message-spec.js +++ b/lib/shared/messages/leave-thread-message-spec.js @@ -1,138 +1,112 @@ // @flow import invariant from 'invariant'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { LeaveThreadMessageData, LeaveThreadMessageInfo, RawLeaveThreadMessageInfo, } from '../../types/messages/leave-thread'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; -import { - ET, - type EntityText, - entityTextToRawString, -} from '../../utils/entity-text'; +import { ET, type EntityText } from '../../utils/entity-text'; import { values } from '../../utils/objects'; import { pluralize } from '../../utils/text-utils'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; import { stringForUser } from '../user-utils'; -import type { - MessageSpec, - MessageTitleParam, - NotificationTextsParams, -} from './message-spec'; +import type { MessageSpec, NotificationTextsParams } from './message-spec'; import { joinResult } from './utils'; export const leaveThreadMessageSpec: MessageSpec< LeaveThreadMessageData, RawLeaveThreadMessageInfo, LeaveThreadMessageInfo, > = Object.freeze({ rawMessageInfoFromServerDBRow(row: Object): RawLeaveThreadMessageInfo { return { type: messageTypes.LEAVE_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawLeaveThreadMessageInfo { const rawLeaveThreadMessageInfo: RawLeaveThreadMessageInfo = { type: messageTypes.LEAVE_THREAD, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, }; return rawLeaveThreadMessageInfo; }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: LeaveThreadMessageInfo = (messageInfo: LeaveThreadMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - createMessageInfo( rawMessageInfo: RawLeaveThreadMessageInfo, creator: RelativeUserInfo, ): LeaveThreadMessageInfo { return { type: messageTypes.LEAVE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, }; }, rawMessageInfoFromMessageData( messageData: LeaveThreadMessageData, id: ?string, ): RawLeaveThreadMessageInfo { invariant(id, 'RawLeaveThreadMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: LeaveThreadMessageInfo): EntityText { const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} left ${ET.thread({ display: 'alwaysDisplayShortName', threadID: messageInfo.threadID, })}`; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { const leaverBeavers = {}; for (const 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} ${params.notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body, }; }, notificationCollapseKey(rawMessageInfo: RawLeaveThreadMessageInfo): string { return joinResult(rawMessageInfo.type, rawMessageInfo.threadID); }, generatesNotifs: async () => undefined, }); diff --git a/lib/shared/messages/message-spec.js b/lib/shared/messages/message-spec.js index 7bfb2a7c7..73454e7e7 100644 --- a/lib/shared/messages/message-spec.js +++ b/lib/shared/messages/message-spec.js @@ -1,120 +1,118 @@ // @flow import type { PlatformDetails } from '../../types/device-types'; import type { Media } from '../../types/media-types'; import type { MessageInfo, ClientDBMessageInfo, RawComposableMessageInfo, RawMessageInfo, RawRobotextMessageInfo, RobotextMessageInfo, } from '../../types/message-types'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo, ThreadType } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; import type { EntityText } from '../../utils/entity-text'; import { type ParserRules } from '../markdown'; -import type { GetMessageTitleViewerContext } from '../message-utils'; export type MessageTitleParam = { +messageInfo: Info, +threadInfo: ThreadInfo, +markdownRules: ParserRules, - +viewerContext?: GetMessageTitleViewerContext, }; export type RawMessageInfoFromServerDBRowParams = { +localID: ?string, +media?: $ReadOnlyArray, +derivedMessages: $ReadOnlyMap< string, RawComposableMessageInfo | RawRobotextMessageInfo, >, }; export type CreateMessageInfoParams = { +threadInfos: { +[id: string]: ThreadInfo }, +createMessageInfoFromRaw: (rawInfo: RawMessageInfo) => ?MessageInfo, +createRelativeUserInfos: ( userIDs: $ReadOnlyArray, ) => RelativeUserInfo[], }; export type RobotextParams = { +threadInfo: ?ThreadInfo, }; export type NotificationTextsParams = { +notifThreadName: (threadInfo: ThreadInfo) => string, +notifTextForSubthreadCreation: ( creator: RelativeUserInfo, threadType: ThreadType, parentThreadInfo: ThreadInfo, childThreadName: ?string, childThreadUIName: string, ) => NotifTexts, +strippedRobotextForMessageInfo: ( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, ) => string, +notificationTexts: ( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ) => NotifTexts, }; export type GeneratesNotifsParams = { +notifTargetUserID: string, +userNotMemberOfSubthreads: Set, +fetchMessageInfoByID: (messageID: string) => Promise, }; export const pushTypes = Object.freeze({ NOTIF: 'notif', RESCIND: 'rescind', }); export type PushType = $Values; export type MessageSpec = { +messageContentForServerDB?: (data: Data | RawInfo) => string, +messageContentForClientDB?: (data: RawInfo) => string, - +messageTitle: (param: MessageTitleParam) => string, + +messageTitle?: (param: MessageTitleParam) => EntityText, +rawMessageInfoFromServerDBRow?: ( row: Object, params: RawMessageInfoFromServerDBRowParams, ) => ?RawInfo, +rawMessageInfoFromClientDB: ( clientDBMessageInfo: ClientDBMessageInfo, ) => RawInfo, +createMessageInfo: ( rawMessageInfo: RawInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ) => ?Info, +rawMessageInfoFromMessageData?: (messageData: Data, id: ?string) => RawInfo, +robotext?: (messageInfo: Info, params: RobotextParams) => EntityText, +shimUnsupportedMessageInfo?: ( rawMessageInfo: RawInfo, platformDetails: ?PlatformDetails, ) => RawInfo | RawUnsupportedMessageInfo, +unshimMessageInfo?: ( unwrapped: RawInfo, messageInfo: RawMessageInfo, ) => ?RawMessageInfo, +notificationTexts?: ( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ) => NotifTexts, +notificationCollapseKey?: (rawMessageInfo: RawInfo) => string, +generatesNotifs: ( rawMessageInfo: RawInfo, params: GeneratesNotifsParams, ) => Promise, +userIDs?: (rawMessageInfo: RawInfo) => $ReadOnlyArray, +startsThread?: boolean, +threadIDs?: (rawMessageInfo: RawInfo) => $ReadOnlyArray, +includedInRepliesCount?: boolean, }; diff --git a/lib/shared/messages/multimedia-message-spec.js b/lib/shared/messages/multimedia-message-spec.js index 2c53b0e3f..c8a5251c5 100644 --- a/lib/shared/messages/multimedia-message-spec.js +++ b/lib/shared/messages/multimedia-message-spec.js @@ -1,382 +1,374 @@ // @flow import invariant from 'invariant'; import { contentStringForMediaArray, multimediaMessagePreview, shimUploadURI, } from '../../media/media-utils'; import type { PlatformDetails } from '../../types/device-types'; import type { Media, Video, Image } from '../../types/media-types'; import { messageTypes, assertMessageType, isMediaMessageType, } from '../../types/message-types'; import type { MessageInfo, RawMessageInfo, RawMultimediaMessageInfo, - MultimediaMessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { ImagesMessageData, RawImagesMessageInfo, ImagesMessageInfo, } from '../../types/messages/images'; import type { MediaMessageData, MediaMessageInfo, RawMediaMessageInfo, } from '../../types/messages/media'; import { getMediaMessageServerDBContentsFromMedia } from '../../types/messages/media'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; +import { ET } from '../../utils/entity-text'; import { translateClientDBMediaInfosToMedia, translateClientDBMediaInfoToImage, } from '../../utils/message-ops-utils'; -import { - createMediaMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; +import { createMediaMessageInfo } from '../message-utils'; import { threadIsGroupChat } from '../thread-utils'; import { stringForUser } from '../user-utils'; import { hasMinCodeVersion } from '../version-utils'; import { pushTypes, type MessageSpec, type MessageTitleParam, type NotificationTextsParams, type RawMessageInfoFromServerDBRowParams, } from './message-spec'; import { joinResult } from './utils'; export const multimediaMessageSpec: MessageSpec< MediaMessageData | ImagesMessageData, RawMediaMessageInfo | RawImagesMessageInfo, MediaMessageInfo | ImagesMessageInfo, > = Object.freeze({ messageContentForServerDB( data: | MediaMessageData | ImagesMessageData | RawMediaMessageInfo | RawImagesMessageInfo, ): string { if (data.type === messageTypes.MULTIMEDIA) { return JSON.stringify( getMediaMessageServerDBContentsFromMedia(data.media), ); } const mediaIDs = data.media.map(media => parseInt(media.id, 10)); return JSON.stringify(mediaIDs); }, messageContentForClientDB( data: RawMediaMessageInfo | RawImagesMessageInfo, ): string { return this.messageContentForServerDB(data); }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawImagesMessageInfo | RawMediaMessageInfo { const messageType = assertMessageType(parseInt(clientDBMessageInfo.type)); invariant( isMediaMessageType(messageType), 'message must be of type IMAGES or MULTIMEDIA', ); invariant( clientDBMessageInfo.media_infos !== null && clientDBMessageInfo.media_infos !== undefined, `media_infos must be defined`, ); const rawMessageInfo: RawImagesMessageInfo | RawMediaMessageInfo = messageType === messageTypes.IMAGES ? { type: messageTypes.IMAGES, threadID: clientDBMessageInfo.thread, creatorID: clientDBMessageInfo.user, time: parseInt(clientDBMessageInfo.time), media: clientDBMessageInfo.media_infos?.map( translateClientDBMediaInfoToImage, ) ?? [], } : { type: messageTypes.MULTIMEDIA, threadID: clientDBMessageInfo.thread, creatorID: clientDBMessageInfo.user, time: parseInt(clientDBMessageInfo.time), media: translateClientDBMediaInfosToMedia(clientDBMessageInfo), }; if (clientDBMessageInfo.local_id) { rawMessageInfo.localID = clientDBMessageInfo.local_id; } if (clientDBMessageInfo.id !== clientDBMessageInfo.local_id) { rawMessageInfo.id = clientDBMessageInfo.id; } return rawMessageInfo; }, messageTitle({ messageInfo, - viewerContext, }: MessageTitleParam) { - let validMessageInfo: MultimediaMessageInfo = (messageInfo: MultimediaMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - } - const creator = stringForUser(validMessageInfo.creator); - const preview = multimediaMessagePreview(validMessageInfo); - return `${creator} ${preview}`; + const creator = ET.user({ userInfo: messageInfo.creator }); + const preview = multimediaMessagePreview(messageInfo); + return ET`${creator} ${preview}`; }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawMediaMessageInfo | RawImagesMessageInfo { const { localID, media } = params; invariant(media, 'Media should be provided'); return createMediaMessageInfo({ threadID: row.threadID.toString(), creatorID: row.creatorID.toString(), media, id: row.id.toString(), localID, time: row.time, }); }, createMessageInfo( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, creator: RelativeUserInfo, ): ?(MediaMessageInfo | ImagesMessageInfo) { if (rawMessageInfo.type === messageTypes.IMAGES) { const messageInfo: ImagesMessageInfo = { type: messageTypes.IMAGES, threadID: rawMessageInfo.threadID, creator, 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, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } }, rawMessageInfoFromMessageData( messageData: MediaMessageData | ImagesMessageData, id: ?string, ): RawMediaMessageInfo | RawImagesMessageInfo { if (messageData.type === messageTypes.IMAGES && id) { return ({ ...messageData, id }: RawImagesMessageInfo); } else if (messageData.type === messageTypes.IMAGES) { return ({ ...messageData }: RawImagesMessageInfo); } else if (id) { return ({ ...messageData, id }: RawMediaMessageInfo); } else { return ({ ...messageData }: RawMediaMessageInfo); } }, shimUnsupportedMessageInfo( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, platformDetails: ?PlatformDetails, ): RawMediaMessageInfo | RawImagesMessageInfo | RawUnsupportedMessageInfo { if (rawMessageInfo.type === messageTypes.IMAGES) { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); return shimmedRawMessageInfo; } else { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); // TODO figure out first native codeVersion supporting video playback if (hasMinCodeVersion(platformDetails, 158)) { 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, }; } }, unshimMessageInfo( unwrapped: RawMediaMessageInfo | RawImagesMessageInfo, messageInfo: RawMessageInfo, ): ?RawMessageInfo { if (unwrapped.type === messageTypes.IMAGES) { return { ...unwrapped, media: unwrapped.media.map(media => { if (media.dimensions) { return media; } const dimensions = preDimensionUploads[media.id]; invariant( dimensions, 'only four photos were uploaded before dimensions were calculated, ' + `and ${media.id} was not one of them`, ); return { ...media, dimensions }; }), }; } else if (unwrapped.type === messageTypes.MULTIMEDIA) { for (const { type } of unwrapped.media) { if (type !== 'photo' && type !== 'video') { return messageInfo; } } } return undefined; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { const media = []; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, 'messageInfo should be multimedia type!', ); for (const 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 = params.notifThreadName(threadInfo); merged = `${body} to ${threadName}`; } merged = `${userString} ${merged}`; return { merged, body, title: threadInfo.uiName, prefix: userString, }; }, notificationCollapseKey( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, ): string { // We use the legacy constant here to collapse both types into one return joinResult( messageTypes.IMAGES, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); }, generatesNotifs: async () => pushTypes.NOTIF, includedInRepliesCount: true, }); function shimMediaMessageInfo( rawMessageInfo: RawMultimediaMessageInfo, platformDetails: ?PlatformDetails, ): RawMultimediaMessageInfo { if (rawMessageInfo.type === messageTypes.IMAGES) { let uriChanged = false; const newMedia: Image[] = []; for (const 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 (const 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); } } // Four photos were uploaded before dimensions were calculated server-side, // and delivered to clients without dimensions in the MultimediaMessageInfo. const preDimensionUploads = { '156642': { width: 1440, height: 1080 }, '156649': { width: 720, height: 803 }, '156794': { width: 720, height: 803 }, '156877': { width: 574, height: 454 }, }; diff --git a/lib/shared/messages/reaction-message-spec.js b/lib/shared/messages/reaction-message-spec.js index 88e85b226..ad7f7cc00 100644 --- a/lib/shared/messages/reaction-message-spec.js +++ b/lib/shared/messages/reaction-message-spec.js @@ -1,225 +1,217 @@ // @flow import invariant from 'invariant'; import type { PlatformDetails } from '../../types/device-types'; import { assertMessageType, messageTypes, type MessageInfo, type ClientDBMessageInfo, } from '../../types/message-types'; import type { ReactionMessageData, RawReactionMessageInfo, ReactionMessageInfo, } from '../../types/messages/reaction'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; -import { removeCreatorAsViewer } from '../message-utils'; +import { ET } from '../../utils/entity-text'; import { threadIsGroupChat } from '../thread-utils'; import { stringForUser } from '../user-utils'; import { hasMinCodeVersion } from '../version-utils'; import { pushTypes, type MessageSpec, type MessageTitleParam, type NotificationTextsParams, type GeneratesNotifsParams, } from './message-spec'; import { assertSingleMessageInfo, joinResult } from './utils'; export const reactionMessageSpec: MessageSpec< ReactionMessageData, RawReactionMessageInfo, ReactionMessageInfo, > = Object.freeze({ messageContentForServerDB( data: ReactionMessageData | RawReactionMessageInfo, ): string { return JSON.stringify({ reaction: data.reaction, action: data.action, }); }, messageContentForClientDB(data: RawReactionMessageInfo): string { return JSON.stringify({ targetMessageID: data.targetMessageID, reaction: data.reaction, action: data.action, }); }, - messageTitle({ - messageInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: ReactionMessageInfo = (messageInfo: ReactionMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - } - - const creator = stringForUser(validMessageInfo.creator); + messageTitle({ messageInfo }: MessageTitleParam) { + const creator = ET.user({ userInfo: messageInfo.creator }); const preview = - validMessageInfo.action === 'add_reaction' + messageInfo.action === 'add_reaction' ? 'reacted to a message' : 'unreacted to a message'; - return `${creator} ${preview}`; + return ET`${creator} ${preview}`; }, rawMessageInfoFromServerDBRow(row: Object): RawReactionMessageInfo { invariant( row.targetMessageID !== null && row.targetMessageID !== undefined, 'targetMessageID should be set', ); const content = JSON.parse(row.content); return { type: messageTypes.REACTION, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), targetMessageID: row.targetMessageID.toString(), reaction: content.reaction, action: content.action, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawReactionMessageInfo { const messageType = assertMessageType(parseInt(clientDBMessageInfo.type)); invariant( messageType === messageTypes.REACTION, 'message must be of type REACTION', ); invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined', ); const content = JSON.parse(clientDBMessageInfo.content); const rawReactionMessageInfo: RawReactionMessageInfo = { type: messageTypes.REACTION, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, targetMessageID: content.targetMessageID, reaction: content.reaction, action: content.action, }; return rawReactionMessageInfo; }, createMessageInfo( rawMessageInfo: RawReactionMessageInfo, creator: RelativeUserInfo, ): ReactionMessageInfo { return { type: messageTypes.REACTION, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, targetMessageID: rawMessageInfo.targetMessageID, reaction: rawMessageInfo.reaction, action: rawMessageInfo.action, }; }, rawMessageInfoFromMessageData( messageData: ReactionMessageData, id: ?string, ): RawReactionMessageInfo { invariant(id, 'RawReactionMessageInfo needs id'); return { ...messageData, id }; }, shimUnsupportedMessageInfo( rawMessageInfo: RawReactionMessageInfo, platformDetails: ?PlatformDetails, ): RawReactionMessageInfo | RawUnsupportedMessageInfo { if (hasMinCodeVersion(platformDetails, 167)) { 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: 'reacted to a message', unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo(unwrapped: RawReactionMessageInfo): RawReactionMessageInfo { return unwrapped; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.REACTION, 'messageInfo should be reaction type', ); const userString = stringForUser(messageInfo.creator); const body = messageInfo.action === 'add_reaction' ? 'reacted to your message' : 'unreacted to your message'; let merged; if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { merged = `${userString} ${body}`; } else { const threadName = params.notifThreadName(threadInfo); merged = `${userString} ${body} in ${threadName}`; } return { merged, body, title: threadInfo.uiName, prefix: userString, }; }, notificationCollapseKey(rawMessageInfo: RawReactionMessageInfo): string { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.targetMessageID, ); }, generatesNotifs: async ( rawMessageInfo: RawReactionMessageInfo, params: GeneratesNotifsParams, ) => { const { notifTargetUserID, fetchMessageInfoByID } = params; const targetMessageInfo = await fetchMessageInfoByID( rawMessageInfo.targetMessageID, ); return targetMessageInfo?.creatorID === notifTargetUserID ? pushTypes.NOTIF : undefined; }, }); diff --git a/lib/shared/messages/remove-members-message-spec.js b/lib/shared/messages/remove-members-message-spec.js index 79e50f020..55e5336d9 100644 --- a/lib/shared/messages/remove-members-message-spec.js +++ b/lib/shared/messages/remove-members-message-spec.js @@ -1,190 +1,163 @@ // @flow import invariant from 'invariant'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { RawRemoveMembersMessageInfo, RemoveMembersMessageData, RemoveMembersMessageInfo, } from '../../types/messages/remove-members'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; import { ET, type EntityText, pluralizeEntityText, - entityTextToRawString, } from '../../utils/entity-text'; import { values } from '../../utils/objects'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; import type { CreateMessageInfoParams, MessageSpec, - MessageTitleParam, NotificationTextsParams, } from './message-spec'; import { joinResult } from './utils'; export const removeMembersMessageSpec: MessageSpec< RemoveMembersMessageData, RawRemoveMembersMessageInfo, RemoveMembersMessageInfo, > = Object.freeze({ messageContentForServerDB( data: RemoveMembersMessageData | RawRemoveMembersMessageInfo, ): string { return JSON.stringify(data.removedUserIDs); }, messageContentForClientDB(data: RawRemoveMembersMessageInfo): string { return this.messageContentForServerDB(data); }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: RemoveMembersMessageInfo = (messageInfo: RemoveMembersMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - validMessageInfo = { - ...validMessageInfo, - removedMembers: validMessageInfo.removedMembers.map(item => ({ - ...item, - isViewer: false, - })), - }; - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - rawMessageInfoFromServerDBRow(row: Object): RawRemoveMembersMessageInfo { 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), }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawRemoveMembersMessageInfo { const content = clientDBMessageInfo.content; invariant( content !== undefined && content !== null, 'content must be defined', ); const rawRemoveMembersMessageInfo: RawRemoveMembersMessageInfo = { type: messageTypes.REMOVE_MEMBERS, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, removedUserIDs: JSON.parse(content), }; return rawRemoveMembersMessageInfo; }, createMessageInfo( rawMessageInfo: RawRemoveMembersMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): RemoveMembersMessageInfo { const removedMembers = params.createRelativeUserInfos( rawMessageInfo.removedUserIDs, ); return { type: messageTypes.REMOVE_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, removedMembers, }; }, rawMessageInfoFromMessageData( messageData: RemoveMembersMessageData, id: ?string, ): RawRemoveMembersMessageInfo { invariant(id, 'RawRemoveMembersMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: RemoveMembersMessageInfo): EntityText { const users = messageInfo.removedMembers; invariant(users.length !== 0, 'added who??'); const creator = ET.user({ userInfo: messageInfo.creator }); const removedUsers = pluralizeEntityText( users.map(user => ET`${ET.user({ userInfo: user })}`), ); return ET`${creator} removed ${removedUsers}`; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { const removedMembersObject = {}; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.REMOVE_MEMBERS, 'messageInfo should be messageTypes.REMOVE_MEMBERS!', ); for (const 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 = params.strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} from ${params.notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; }, notificationCollapseKey(rawMessageInfo: RawRemoveMembersMessageInfo): string { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); }, generatesNotifs: async () => undefined, userIDs(rawMessageInfo: RawRemoveMembersMessageInfo): $ReadOnlyArray { return rawMessageInfo.removedUserIDs; }, }); diff --git a/lib/shared/messages/restore-entry-message-spec.js b/lib/shared/messages/restore-entry-message-spec.js index 873bea751..886d427f4 100644 --- a/lib/shared/messages/restore-entry-message-spec.js +++ b/lib/shared/messages/restore-entry-message-spec.js @@ -1,161 +1,138 @@ // @flow import invariant from 'invariant'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { RawRestoreEntryMessageInfo, RestoreEntryMessageData, RestoreEntryMessageInfo, } from '../../types/messages/restore-entry'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; import { prettyDate } from '../../utils/date-utils'; -import { - ET, - type EntityText, - entityTextToRawString, -} from '../../utils/entity-text'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; +import { ET, type EntityText } from '../../utils/entity-text'; import { stringForUser } from '../user-utils'; import { pushTypes, type MessageSpec, - type MessageTitleParam, type NotificationTextsParams, } from './message-spec'; import { assertSingleMessageInfo } from './utils'; export const restoreEntryMessageSpec: MessageSpec< RestoreEntryMessageData, RawRestoreEntryMessageInfo, RestoreEntryMessageInfo, > = Object.freeze({ messageContentForServerDB( data: RestoreEntryMessageData | RawRestoreEntryMessageInfo, ): string { return JSON.stringify({ entryID: data.entryID, date: data.date, text: data.text, }); }, messageContentForClientDB(data: RawRestoreEntryMessageInfo): string { return this.messageContentForServerDB(data); }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: RestoreEntryMessageInfo = (messageInfo: RestoreEntryMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - rawMessageInfoFromServerDBRow(row: Object): RawRestoreEntryMessageInfo { 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, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawRestoreEntryMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined', ); const content = JSON.parse(clientDBMessageInfo.content); const rawRestoreEntryMessageInfo: RawRestoreEntryMessageInfo = { type: messageTypes.RESTORE_ENTRY, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, entryID: content.entryID, date: content.date, text: content.text, }; return rawRestoreEntryMessageInfo; }, createMessageInfo( rawMessageInfo: RawRestoreEntryMessageInfo, creator: RelativeUserInfo, ): RestoreEntryMessageInfo { return { type: messageTypes.RESTORE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; }, rawMessageInfoFromMessageData( messageData: RestoreEntryMessageData, id: ?string, ): RawRestoreEntryMessageInfo { invariant(id, 'RawRestoreEntryMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: RestoreEntryMessageInfo): EntityText { const date = prettyDate(messageInfo.date); const creator = ET.user({ userInfo: messageInfo.creator }); const { text } = messageInfo; return ET`${creator} restored an event scheduled for ${date}: "${text}"`; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { 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 ${params.notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; }, generatesNotifs: async () => pushTypes.NOTIF, }); diff --git a/lib/shared/messages/text-message-spec.js b/lib/shared/messages/text-message-spec.js index 75e305ef3..bff5a4ca7 100644 --- a/lib/shared/messages/text-message-spec.js +++ b/lib/shared/messages/text-message-spec.js @@ -1,202 +1,203 @@ // @flow import invariant from 'invariant'; import * as SimpleMarkdown from 'simple-markdown'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { RawTextMessageInfo, TextMessageData, TextMessageInfo, } from '../../types/messages/text'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; +import { ET } from '../../utils/entity-text'; import { type ASTNode, type SingleASTNode, stripSpoilersFromNotifications, stripSpoilersFromMarkdownAST, } from '../markdown'; import { threadIsGroupChat } from '../thread-utils'; import { stringForUser } from '../user-utils'; import { pushTypes, type MessageSpec, type NotificationTextsParams, type RawMessageInfoFromServerDBRowParams, } from './message-spec'; import { assertSingleMessageInfo } from './utils'; /** * most of the markdown leaves contain `content` field * (it is an array or a string) apart from lists, * which have `items` field (that holds an array) */ const rawTextFromMarkdownAST = (node: ASTNode): string => { if (Array.isArray(node)) { return node.map(rawTextFromMarkdownAST).join(''); } const { content, items } = node; if (content && typeof content === 'string') { return content; } else if (items) { return rawTextFromMarkdownAST(items); } else if (content) { return rawTextFromMarkdownAST(content); } return ''; }; const getFirstNonQuotedRawLine = ( nodes: $ReadOnlyArray, ): string => { let result = 'message'; for (const node of nodes) { if (node.type === 'blockQuote') { result = 'quoted message'; } else { const rawText = rawTextFromMarkdownAST(node); if (!rawText || !rawText.replace(/\s/g, '')) { // handles the case of an empty(or containing only white spaces) // new line that usually occurs between a quote and the rest // of the message(we don't want it as a title, thus continue) continue; } return rawText; } } return result; }; export const textMessageSpec: MessageSpec< TextMessageData, RawTextMessageInfo, TextMessageInfo, > = Object.freeze({ messageContentForServerDB( data: TextMessageData | RawTextMessageInfo, ): string { return data.text; }, messageContentForClientDB(data: RawTextMessageInfo): string { return this.messageContentForServerDB(data); }, messageTitle({ messageInfo, markdownRules }) { const { text } = messageInfo; const parser = SimpleMarkdown.parserFor(markdownRules); const ast = stripSpoilersFromMarkdownAST( parser(text, { disableAutoBlockNewlines: true }), ); - return getFirstNonQuotedRawLine(ast).trim(); + return ET`${getFirstNonQuotedRawLine(ast).trim()}`; }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawTextMessageInfo { const rawTextMessageInfo: RawTextMessageInfo = { type: messageTypes.TEXT, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), text: row.content, }; if (params.localID) { rawTextMessageInfo.localID = params.localID; } return rawTextMessageInfo; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawTextMessageInfo { const rawTextMessageInfo: RawTextMessageInfo = { type: messageTypes.TEXT, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, text: clientDBMessageInfo.content ?? '', }; if (clientDBMessageInfo.local_id) { rawTextMessageInfo.localID = clientDBMessageInfo.local_id; } if (clientDBMessageInfo.id !== clientDBMessageInfo.local_id) { rawTextMessageInfo.id = clientDBMessageInfo.id; } return rawTextMessageInfo; }, createMessageInfo( rawMessageInfo: RawTextMessageInfo, creator: RelativeUserInfo, ): TextMessageInfo { const messageInfo: TextMessageInfo = { type: messageTypes.TEXT, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, text: rawMessageInfo.text, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; }, rawMessageInfoFromMessageData( messageData: TextMessageData, id: ?string, ): RawTextMessageInfo { if (id) { return { ...messageData, id }; } else { return { ...messageData }; } }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ): NotifTexts { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.TEXT, 'messageInfo should be messageTypes.TEXT!', ); const notificationTextWithoutSpoilers = stripSpoilersFromNotifications( messageInfo.text, ); if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { return { merged: `${threadInfo.uiName}: ${notificationTextWithoutSpoilers}`, body: notificationTextWithoutSpoilers, title: threadInfo.uiName, }; } else { const userString = stringForUser(messageInfo.creator); const threadName = params.notifThreadName(threadInfo); return { merged: `${userString} to ${threadName}: ${notificationTextWithoutSpoilers}`, body: notificationTextWithoutSpoilers, title: threadInfo.uiName, prefix: `${userString}:`, }; } }, generatesNotifs: async () => pushTypes.NOTIF, includedInRepliesCount: true, }); diff --git a/lib/shared/messages/unsupported-message-spec.js b/lib/shared/messages/unsupported-message-spec.js index dc007f021..2bda3ea09 100644 --- a/lib/shared/messages/unsupported-message-spec.js +++ b/lib/shared/messages/unsupported-message-spec.js @@ -1,103 +1,77 @@ // @flow import invariant from 'invariant'; import { messageTypes, type ClientDBMessageInfo, } from '../../types/message-types'; import type { RawUnsupportedMessageInfo, UnsupportedMessageInfo, } from '../../types/messages/unsupported'; import type { RelativeUserInfo } from '../../types/user-types'; -import { - ET, - type EntityText, - entityTextToRawString, -} from '../../utils/entity-text'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; -import { - pushTypes, - type MessageSpec, - type MessageTitleParam, -} from './message-spec'; +import { ET, type EntityText } from '../../utils/entity-text'; +import { pushTypes, type MessageSpec } from './message-spec'; export const unsupportedMessageSpec: MessageSpec< null, RawUnsupportedMessageInfo, UnsupportedMessageInfo, > = Object.freeze({ messageContentForClientDB(data: RawUnsupportedMessageInfo): string { return JSON.stringify({ robotext: data.robotext, dontPrefixCreator: data.dontPrefixCreator, unsupportedMessageInfo: data.unsupportedMessageInfo, }); }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawUnsupportedMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined', ); const content = JSON.parse(clientDBMessageInfo.content); const rawUnsupportedMessageInfo: RawUnsupportedMessageInfo = { type: messageTypes.UNSUPPORTED, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, robotext: content.robotext, dontPrefixCreator: content.dontPrefixCreator, unsupportedMessageInfo: content.unsupportedMessageInfo, }; return rawUnsupportedMessageInfo; }, createMessageInfo( rawMessageInfo: RawUnsupportedMessageInfo, creator: RelativeUserInfo, ): UnsupportedMessageInfo { return { type: messageTypes.UNSUPPORTED, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, robotext: rawMessageInfo.robotext, dontPrefixCreator: rawMessageInfo.dontPrefixCreator, unsupportedMessageInfo: rawMessageInfo.unsupportedMessageInfo, }; }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: UnsupportedMessageInfo = (messageInfo: UnsupportedMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - robotext(messageInfo: UnsupportedMessageInfo): EntityText { if (messageInfo.dontPrefixCreator) { return ET`${messageInfo.robotext}`; } const creator = ET.user({ userInfo: messageInfo.creator }); return ET`${creator} ${messageInfo.robotext}`; }, generatesNotifs: async () => pushTypes.NOTIF, }); diff --git a/lib/shared/messages/update-relationship-message-spec.js b/lib/shared/messages/update-relationship-message-spec.js index a73e25870..f4f292de4 100644 --- a/lib/shared/messages/update-relationship-message-spec.js +++ b/lib/shared/messages/update-relationship-message-spec.js @@ -1,203 +1,176 @@ // @flow import invariant from 'invariant'; import type { PlatformDetails } from '../../types/device-types'; import { messageTypes } from '../../types/message-types'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported'; import type { RawUpdateRelationshipMessageInfo, UpdateRelationshipMessageData, UpdateRelationshipMessageInfo, } from '../../types/messages/update-relationship'; import type { NotifTexts } from '../../types/notif-types'; import type { ThreadInfo } from '../../types/thread-types'; import type { RelativeUserInfo } from '../../types/user-types'; -import { - ET, - type EntityText, - entityTextToRawString, -} from '../../utils/entity-text'; -import { - robotextForMessageInfo, - removeCreatorAsViewer, -} from '../message-utils'; +import { ET, type EntityText } from '../../utils/entity-text'; import { stringForUser } from '../user-utils'; import { hasMinCodeVersion } from '../version-utils'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, - type MessageTitleParam, } from './message-spec'; import { assertSingleMessageInfo } from './utils'; export const updateRelationshipMessageSpec: MessageSpec< UpdateRelationshipMessageData, RawUpdateRelationshipMessageInfo, UpdateRelationshipMessageInfo, > = Object.freeze({ messageContentForServerDB( data: UpdateRelationshipMessageData | RawUpdateRelationshipMessageInfo, ): string { return JSON.stringify({ operation: data.operation, targetID: data.targetID, }); }, messageContentForClientDB(data: RawUpdateRelationshipMessageInfo): string { return this.messageContentForServerDB(data); }, - messageTitle({ - messageInfo, - threadInfo, - viewerContext, - }: MessageTitleParam) { - let validMessageInfo: UpdateRelationshipMessageInfo = (messageInfo: UpdateRelationshipMessageInfo); - if (viewerContext === 'global_viewer') { - validMessageInfo = removeCreatorAsViewer(validMessageInfo); - validMessageInfo = { - ...validMessageInfo, - target: { ...validMessageInfo.target, isViewer: false }, - }; - } - return entityTextToRawString( - robotextForMessageInfo(validMessageInfo, threadInfo), - ); - }, - rawMessageInfoFromServerDBRow(row: Object): RawUpdateRelationshipMessageInfo { 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, }; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawUpdateRelationshipMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined', ); const content = JSON.parse(clientDBMessageInfo.content); const rawUpdateRelationshipMessageInfo: RawUpdateRelationshipMessageInfo = { type: messageTypes.UPDATE_RELATIONSHIP, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, targetID: content.targetID, operation: content.operation, }; return rawUpdateRelationshipMessageInfo; }, createMessageInfo( rawMessageInfo: RawUpdateRelationshipMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ?UpdateRelationshipMessageInfo { const target = params.createRelativeUserInfos([rawMessageInfo.targetID])[0]; if (!target) { return null; } return { type: messageTypes.UPDATE_RELATIONSHIP, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, target, time: rawMessageInfo.time, operation: rawMessageInfo.operation, }; }, rawMessageInfoFromMessageData( messageData: UpdateRelationshipMessageData, id: ?string, ): RawUpdateRelationshipMessageInfo { invariant(id, 'RawUpdateRelationshipMessageInfo needs id'); return { ...messageData, id }; }, robotext(messageInfo: UpdateRelationshipMessageInfo): EntityText { const creator = ET.user({ userInfo: messageInfo.creator }); if (messageInfo.operation === 'request_sent') { const target = ET.user({ userInfo: messageInfo.target }); return ET`${creator} sent ${target} a friend request`; } else if (messageInfo.operation === 'request_accepted') { const targetPossessive = ET.user({ userInfo: messageInfo.target, possessive: true, }); return ET`${creator} accepted ${targetPossessive} friend request`; } invariant( false, `Invalid operation ${messageInfo.operation} ` + `of message with type ${messageInfo.type}`, ); }, shimUnsupportedMessageInfo( rawMessageInfo: RawUpdateRelationshipMessageInfo, platformDetails: ?PlatformDetails, ): RawUpdateRelationshipMessageInfo | RawUnsupportedMessageInfo { 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, }; }, unshimMessageInfo( unwrapped: RawUpdateRelationshipMessageInfo, ): RawUpdateRelationshipMessageInfo { return unwrapped; }, notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): NotifTexts { 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, }; }, generatesNotifs: async () => pushTypes.NOTIF, }); diff --git a/lib/utils/entity-text.js b/lib/utils/entity-text.js index 8909588fc..110bffa5d 100644 --- a/lib/utils/entity-text.js +++ b/lib/utils/entity-text.js @@ -1,354 +1,362 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useENSNames } from '../hooks/ens-cache'; import { threadNoun } from '../shared/thread-utils'; import { stringForUser } from '../shared/user-utils'; import { threadTypes, type ThreadType, type RawThreadInfo, type ThreadInfo, } from '../types/thread-types'; import { basePluralize } from '../utils/text-utils'; type UserEntity = { +type: 'user', +id: string, +username?: ?string, +isViewer?: ?boolean, +possessive?: ?boolean, // eg. `user's` instead of `user` }; // Comments explain how thread name will appear from user4's perspective type ThreadEntity = | { +type: 'thread', +id: string, +name?: ?string, // displays threadInfo.name if set, or 'user1, user2, and user3' +display: 'uiName', +uiName: string, } | { +type: 'thread', +id: string, +name?: ?string, // displays threadInfo.name if set, or eg. 'this thread' or 'this chat' +display: 'shortName', +threadType?: ?ThreadType, +alwaysDisplayShortName?: ?boolean, // don't default to name +subchannel?: ?boolean, // short name should be "subchannel" +possessive?: ?boolean, // eg. `this thread's` instead of `this thread` }; type ColorEntity = { +type: 'color', +hex: string, }; type EntityTextComponent = UserEntity | ThreadEntity | ColorEntity | string; export type EntityText = $ReadOnlyArray; const entityTextFunction = ( strings: $ReadOnlyArray, ...entities: $ReadOnlyArray ) => { const result = []; for (let i = 0; i < strings.length; i++) { const str = strings[i]; if (str) { result.push(str); } const entity = entities[i]; if (!entity) { continue; } if (typeof entity === 'string') { const lastResult = result.length > 0 && result[result.length - 1]; if (typeof lastResult === 'string') { result[result.length - 1] = lastResult + entity; } else { result.push(entity); } } else if (Array.isArray(entity)) { const [firstEntity, ...restOfEntity] = entity; const lastResult = result.length > 0 && result[result.length - 1]; if (typeof lastResult === 'string' && typeof firstEntity === 'string') { result[result.length - 1] = lastResult + firstEntity; } else if (firstEntity) { result.push(firstEntity); } result.push(...restOfEntity); } else { result.push(entity); } } return result; }; // defaults to shortName type EntityTextThreadInput = | { +display: 'uiName', +threadInfo: ThreadInfo, } | { +display?: 'shortName', +threadInfo: ThreadInfo | RawThreadInfo, +subchannel?: ?boolean, +possessive?: ?boolean, } | { +display: 'alwaysDisplayShortName', +threadInfo: ThreadInfo | RawThreadInfo, +possessive?: ?boolean, } | { +display: 'alwaysDisplayShortName', +threadID: string, +possessive?: ?boolean, }; entityTextFunction.thread = (input: EntityTextThreadInput) => { if (input.display === 'uiName') { const { threadInfo } = input; return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: threadInfo.uiName, }; } if (input.display === 'alwaysDisplayShortName' && input.threadID) { const { threadID, possessive } = input; return { type: 'thread', id: threadID, name: undefined, display: 'shortName', threadType: undefined, alwaysDisplayShortName: true, possessive, }; } else if (input.display === 'alwaysDisplayShortName' && input.threadInfo) { const { threadInfo, possessive } = input; return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'shortName', threadType: threadInfo.type, alwaysDisplayShortName: true, possessive, }; } else if (input.display === 'shortName' || !input.display) { const { threadInfo, subchannel, possessive } = input; return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'shortName', threadType: threadInfo.type, subchannel, possessive, }; } invariant( false, `ET.thread passed unexpected display type: ${input.display}`, ); }; type EntityTextUserInput = { +userInfo: { +id: string, +username?: ?string, +isViewer?: ?boolean, ... }, +possessive?: ?boolean, }; entityTextFunction.user = (input: EntityTextUserInput) => ({ type: 'user', id: input.userInfo.id, username: input.userInfo.username, isViewer: input.userInfo.isViewer, possessive: input.possessive, }); type EntityTextColorInput = { +hex: string }; entityTextFunction.color = (input: EntityTextColorInput) => ({ type: 'color', hex: input.hex, }); // ET is a JS tag function used in template literals, eg. ET`something` // It allows you to compose raw text and "entities" together type EntityTextFunction = (( strings: $ReadOnlyArray, ...entities: $ReadOnlyArray ) => EntityText) & { +thread: EntityTextThreadInput => ThreadEntity, +user: EntityTextUserInput => UserEntity, +color: EntityTextColorInput => ColorEntity, ... }; const ET: EntityTextFunction = entityTextFunction; type MakePossessiveInput = { +str: string, +isViewer?: ?boolean }; function makePossessive(input: MakePossessiveInput) { if (input.isViewer) { return 'your'; } return `${input.str}’s`; } function getNameForThreadEntity( entity: ThreadEntity, threadID: ?string, ): string { const { name: userGeneratedName, display } = entity; if (entity.display === 'uiName') { return userGeneratedName ? userGeneratedName : entity.uiName; } invariant( entity.display === 'shortName', `getNameForThreadEntity can't handle thread entity display ${display}`, ); let name = userGeneratedName; if (!name || entity.alwaysDisplayShortName) { const threadType = entity.threadType ?? threadTypes.PERSONAL; name = entity.subchannel ? 'subchannel' : threadNoun(threadType); if (entity.id === threadID) { name = `this ${name}`; } } if (entity.possessive) { name = makePossessive({ str: name }); } return name; } -function getNameForUserEntity(entity: UserEntity): string { - const str = stringForUser(entity); - const { isViewer, possessive } = entity; - if (!possessive) { +function getNameForUserEntity( + entity: UserEntity, + ignoreViewer: ?boolean, +): string { + const isViewer = entity.isViewer && !ignoreViewer; + const entityWithIsViewerIgnored = { ...entity, isViewer }; + const str = stringForUser(entityWithIsViewerIgnored); + if (!entityWithIsViewerIgnored.possessive) { return str; } return makePossessive({ str, isViewer }); } +type EntityTextToRawStringParams = { + +threadID?: ?string, + +ignoreViewer?: ?boolean, +}; function entityTextToRawString( entityText: EntityText, - threadID: ?string, + params?: EntityTextToRawStringParams, ): string { const textParts = entityText.map(entity => { if (typeof entity === 'string') { return entity; } else if (entity.type === 'thread') { - return getNameForThreadEntity(entity, threadID); + return getNameForThreadEntity(entity, params?.threadID); } else if (entity.type === 'color') { return entity.hex; } else if (entity.type === 'user') { - return getNameForUserEntity(entity); + return getNameForUserEntity(entity, params?.ignoreViewer); } else { invariant( false, `entityTextToRawString can't handle entity type ${entity.type}`, ); } }); return textParts.join(''); } type RenderFunctions = { +renderText: ({ +text: string }) => React.Node, +renderThread: ({ +id: string, +name: string }) => React.Node, +renderColor: ({ +hex: string }) => React.Node, }; function entityTextToReact( entityText: EntityText, threadID: string, renderFuncs: RenderFunctions, ): React.Node { const { renderText, renderThread, renderColor } = renderFuncs; return entityText.map((entity, i) => { const key = `text${i}`; if (typeof entity === 'string') { return ( {renderText({ text: entity })} ); } else if (entity.type === 'thread') { const { id } = entity; const name = getNameForThreadEntity(entity, threadID); if (id === threadID) { return name; } else { return ( {renderThread({ id, name })} ); } } else if (entity.type === 'color') { return ( {renderColor({ hex: entity.hex })} ); } else if (entity.type === 'user') { return getNameForUserEntity(entity); } else { invariant( false, `entityTextToReact can't handle entity type ${entity.type}`, ); } }); } function pluralizeEntityText( nouns: $ReadOnlyArray, maxNumberOfNouns: number = 3, ): EntityText { return basePluralize( nouns, maxNumberOfNouns, (a: EntityText | string, b: ?EntityText | string) => b ? ET`${a}${b}` : ET`${a}`, ); } function useENSNamesForEntityText(entityText: EntityText): EntityText { const allObjects = React.useMemo( () => entityText.map(entity => typeof entity === 'string' ? { type: 'text', text: entity } : entity, ), [entityText], ); const objectsWithENSNames = useENSNames(allObjects); return React.useMemo( () => objectsWithENSNames.map(entity => entity.type === 'text' ? entity.text : entity, ), [objectsWithENSNames], ); } export { ET, entityTextToRawString, entityTextToReact, pluralizeEntityText, useENSNamesForEntityText, }; diff --git a/native/chat/chat-item-height-measurer.react.js b/native/chat/chat-item-height-measurer.react.js index eaabbad73..8446a50df 100644 --- a/native/chat/chat-item-height-measurer.react.js +++ b/native/chat/chat-item-height-measurer.react.js @@ -1,179 +1,180 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import type { ChatMessageItem } from 'lib/selectors/chat-selectors'; import { messageID } from 'lib/shared/message-utils'; import { messageTypes, type MessageType } from 'lib/types/message-types'; import { entityTextToRawString } from 'lib/utils/entity-text'; import NodeHeightMeasurer from '../components/node-height-measurer.react'; import { InputStateContext } from '../input/input-state'; import type { MeasurementTask } from './chat-context-provider.react'; import { useComposedMessageMaxWidth } from './composed-message-width'; import { dummyNodeForRobotextMessageHeightMeasurement } from './inner-robotext-message.react'; import { dummyNodeForTextMessageHeightMeasurement } from './inner-text-message.react'; import { MessageListContextProvider } from './message-list-types'; import { multimediaMessageContentSizes } from './multimedia-message-utils'; import { chatMessageItemKey } from './utils'; type Props = { +measurement: MeasurementTask, }; const heightMeasurerKey = (item: ChatMessageItem) => { if (item.itemType !== 'message') { return null; } const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return messageInfo.text; } else if (item.robotext) { - return entityTextToRawString(item.robotext, item.messageInfo.threadID); + const { threadID } = item.messageInfo; + return entityTextToRawString(item.robotext, { threadID }); } return null; }; const 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) { return dummyNodeForRobotextMessageHeightMeasurement( item.robotext, item.messageInfo.threadID, ); } invariant(false, 'NodeHeightMeasurer asked for dummy for non-text message'); }; function ChatItemHeightMeasurer(props: Props) { const composedMessageMaxWidth = useComposedMessageMaxWidth(); const inputState = React.useContext(InputStateContext); const inputStatePendingUploads = inputState?.pendingUploads; const { measurement } = props; const { threadInfo } = measurement; const heightMeasurerMergeItem = React.useCallback( (item: ChatMessageItem, height: ?number) => { if (item.itemType !== 'message') { return item; } const { messageInfo } = item; const messageType: MessageType = messageInfo.type; invariant( messageType !== messageTypes.SIDEBAR_SOURCE, 'Sidebar source messages should be replaced by sourceMessage before being measured', ); if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; const id = messageID(messageInfo); const pendingUploads = inputStatePendingUploads?.[id]; const sizes = multimediaMessageContentSizes( messageInfo, composedMessageMaxWidth, ); return { itemType: 'message', messageShapeType: 'multimedia', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, threadCreatedFromMessage: item.threadCreatedFromMessage, pendingUploads, reactions: item.reactions, ...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, threadCreatedFromMessage: item.threadCreatedFromMessage, contentHeight: height, reactions: item.reactions, }; } invariant( item.messageInfoType !== 'composable', 'ChatItemHeightMeasurer was handed a messageInfoType=composable, but ' + `does not know how to handle MessageType ${messageInfo.type}`, ); invariant( item.messageInfoType === 'robotext', 'ChatItemHeightMeasurer was handed a messageInfoType that it does ' + `not recognize: ${item.messageInfoType}`, ); return { itemType: 'message', messageShapeType: 'robotext', messageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, threadCreatedFromMessage: item.threadCreatedFromMessage, robotext: item.robotext, contentHeight: height, reactions: item.reactions, }; }, [composedMessageMaxWidth, inputStatePendingUploads, threadInfo], ); return ( ); } const MemoizedChatItemHeightMeasurer: React.ComponentType = React.memo( ChatItemHeightMeasurer, ); export default MemoizedChatItemHeightMeasurer; diff --git a/native/chat/inner-robotext-message.react.js b/native/chat/inner-robotext-message.react.js index 344cce6b0..e4553aaf0 100644 --- a/native/chat/inner-robotext-message.react.js +++ b/native/chat/inner-robotext-message.react.js @@ -1,143 +1,143 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, TouchableWithoutFeedback, View } from 'react-native'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { entityTextToReact, entityTextToRawString, useENSNamesForEntityText, type EntityText, } from 'lib/utils/entity-text'; import Markdown from '../markdown/markdown.react'; import { inlineMarkdownRules } from '../markdown/rules.react'; import { useSelector } from '../redux/redux-utils'; import { useOverlayStyles } from '../themes/colors'; import type { ChatRobotextMessageInfoItemWithHeight } from '../types/chat-types'; import { useNavigateToThread } from './message-list-types'; function dummyNodeForRobotextMessageHeightMeasurement( robotext: EntityText, threadID: string, ): React.Element { return ( - {entityTextToRawString(robotext, threadID)} + {entityTextToRawString(robotext, { threadID })} ); } type InnerRobotextMessageProps = { +item: ChatRobotextMessageInfoItemWithHeight, +onPress: () => void, +onLongPress?: () => void, }; function InnerRobotextMessage(props: InnerRobotextMessageProps): React.Node { const { item, onLongPress, onPress } = props; const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const styles = useOverlayStyles(unboundStyles); const { messageInfo, robotext } = item; const { threadID } = messageInfo; const robotextWithENSNames = useENSNamesForEntityText(robotext); const textParts = React.useMemo(() => { const darkColor = activeTheme === 'dark'; return entityTextToReact(robotextWithENSNames, threadID, { // eslint-disable-next-line react/display-name renderText: ({ text }) => ( {text} ), // eslint-disable-next-line react/display-name renderThread: ({ id, name }) => , // eslint-disable-next-line react/display-name renderColor: ({ hex }) => , }); }, [robotextWithENSNames, activeTheme, threadID, styles.robotext]); const viewStyle = [styles.robotextContainer]; if (!__DEV__) { // We don't force view height in dev mode because we // want to measure it in Message to see if it's correct viewStyle.push({ height: item.contentHeight }); } return ( {textParts} ); } type ThreadEntityProps = { +id: string, +name: string, }; function ThreadEntity(props: ThreadEntityProps) { const threadID = props.id; const threadInfo = useSelector(state => threadInfoSelector(state)[threadID]); const styles = useOverlayStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const onPressThread = React.useCallback(() => { invariant(threadInfo, 'onPressThread should have threadInfo'); navigateToThread({ threadInfo }); }, [threadInfo, navigateToThread]); if (!threadInfo) { return {props.name}; } return ( {props.name} ); } function ColorEntity(props: { +color: string }) { const colorStyle = { color: props.color }; return {props.color}; } const unboundStyles = { link: { color: 'link', }, robotextContainer: { paddingTop: 6, paddingBottom: 11, paddingHorizontal: 24, }, robotext: { color: 'listForegroundSecondaryLabel', fontFamily: 'Arial', fontSize: 15, textAlign: 'center', }, dummyRobotext: { fontFamily: 'Arial', fontSize: 15, textAlign: 'center', }, }; const MemoizedInnerRobotextMessage: React.ComponentType = React.memo( InnerRobotextMessage, ); export { dummyNodeForRobotextMessageHeightMeasurement, MemoizedInnerRobotextMessage as InnerRobotextMessage, };