diff --git a/lib/shared/messages/multimedia-message-spec.js b/lib/shared/messages/multimedia-message-spec.js index fc04237ac..11824c7d0 100644 --- a/lib/shared/messages/multimedia-message-spec.js +++ b/lib/shared/messages/multimedia-message-spec.js @@ -1,377 +1,378 @@ // @flow import invariant from 'invariant'; import { pushTypes, type MessageSpec, type MessageTitleParam, type RawMessageInfoFromServerDBRowParams, } from './message-spec.js'; import { joinResult } from './utils.js'; import { contentStringForMediaArray, multimediaMessagePreview, shimUploadURI, } from '../../media/media-utils.js'; import type { PlatformDetails } from '../../types/device-types.js'; import type { Media, Video, Image } from '../../types/media-types.js'; import { messageTypes, assertMessageType, isMediaMessageType, } from '../../types/message-types.js'; import type { MessageInfo, RawMessageInfo, RawMultimediaMessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import type { ImagesMessageData, RawImagesMessageInfo, ImagesMessageInfo, } from '../../types/messages/images.js'; import type { MediaMessageData, MediaMessageInfo, RawMediaMessageInfo, } from '../../types/messages/media.js'; import { getMediaMessageServerDBContentsFromMedia } from '../../types/messages/media.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET } from '../../utils/entity-text.js'; import { translateClientDBMediaInfosToMedia, translateClientDBMediaInfoToImage, } from '../../utils/message-ops-utils.js'; import { createMediaMessageInfo } from '../message-utils.js'; import { threadIsGroupChat } from '../thread-utils.js'; import { hasMinCodeVersion } from '../version-utils.js'; 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`, ); let 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 = { ...rawMessageInfo, localID: clientDBMessageInfo.local_id, }; } if (clientDBMessageInfo.id !== clientDBMessageInfo.local_id) { rawMessageInfo = { ...rawMessageInfo, id: clientDBMessageInfo.id, }; } return rawMessageInfo; }, messageTitle({ messageInfo, }: MessageTitleParam) { 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) { let messageInfo: ImagesMessageInfo = { type: messageTypes.IMAGES, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo = { ...messageInfo, id: rawMessageInfo.id }; } if (rawMessageInfo.localID) { messageInfo = { ...messageInfo, localID: rawMessageInfo.localID }; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { let messageInfo: MediaMessageInfo = { type: messageTypes.MULTIMEDIA, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo = { ...messageInfo, id: rawMessageInfo.id }; } if (rawMessageInfo.localID) { messageInfo = { ...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); + const { sidebarCreation, ...rest } = messageData; + if (rest.type === messageTypes.IMAGES && id) { + return ({ ...rest, id }: RawImagesMessageInfo); + } else if (rest.type === messageTypes.IMAGES) { + return ({ ...rest }: RawImagesMessageInfo); } else if (id) { - return ({ ...messageData, id }: RawMediaMessageInfo); + return ({ ...rest, id }: RawMediaMessageInfo); } else { - return ({ ...messageData }: RawMediaMessageInfo); + return ({ ...rest }: 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; }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): Promise { 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 creator = ET.user({ userInfo: messageInfos[0].creator }); let body, merged; if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { body = `sent you ${contentString}`; merged = body; } else { body = `sent ${contentString}`; const thread = ET.thread({ display: 'shortName', threadInfo }); merged = ET`${body} to ${thread}`; } merged = ET`${creator} ${merged}`; return { merged, body, title: threadInfo.uiName, prefix: ET`${creator}`, }; }, 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/text-message-spec.js b/lib/shared/messages/text-message-spec.js index 4e4f02d2d..1323eeaf9 100644 --- a/lib/shared/messages/text-message-spec.js +++ b/lib/shared/messages/text-message-spec.js @@ -1,207 +1,208 @@ // @flow import invariant from 'invariant'; import * as SimpleMarkdown from 'simple-markdown'; import { pushTypes, type MessageSpec, type RawMessageInfoFromServerDBRowParams, } from './message-spec.js'; import { assertSingleMessageInfo } from './utils.js'; import { messageTypes } from '../../types/message-types.js'; import type { MessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import type { RawTextMessageInfo, TextMessageData, TextMessageInfo, } from '../../types/messages/text.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET } from '../../utils/entity-text.js'; import { type ASTNode, type SingleASTNode, stripSpoilersFromNotifications, stripSpoilersFromMarkdownAST, } from '../markdown.js'; import { threadIsGroupChat } from '../thread-utils.js'; /** * 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 ET`${getFirstNonQuotedRawLine(ast).trim()}`; }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawTextMessageInfo { let 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 = { ...rawTextMessageInfo, localID: params.localID }; } return rawTextMessageInfo; }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawTextMessageInfo { let rawTextMessageInfo: RawTextMessageInfo = { type: messageTypes.TEXT, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, text: clientDBMessageInfo.content ?? '', }; if (clientDBMessageInfo.local_id) { rawTextMessageInfo = { ...rawTextMessageInfo, localID: clientDBMessageInfo.local_id, }; } if (clientDBMessageInfo.id !== clientDBMessageInfo.local_id) { rawTextMessageInfo = { ...rawTextMessageInfo, id: clientDBMessageInfo.id, }; } return rawTextMessageInfo; }, createMessageInfo( rawMessageInfo: RawTextMessageInfo, creator: RelativeUserInfo, ): TextMessageInfo { let messageInfo: TextMessageInfo = { type: messageTypes.TEXT, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, text: rawMessageInfo.text, }; if (rawMessageInfo.id) { messageInfo = { ...messageInfo, id: rawMessageInfo.id }; } if (rawMessageInfo.localID) { messageInfo = { ...messageInfo, localID: rawMessageInfo.localID }; } return messageInfo; }, rawMessageInfoFromMessageData( messageData: TextMessageData, id: ?string, ): RawTextMessageInfo { + const { sidebarCreation, ...rest } = messageData; if (id) { - return { ...messageData, id }; + return { ...rest, id }; } else { - return { ...messageData }; + return { ...rest }; } }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): Promise { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.TEXT, 'messageInfo should be messageTypes.TEXT!', ); const notificationTextWithoutSpoilers = stripSpoilersFromNotifications( messageInfo.text, ); if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { const thread = ET.thread({ display: 'uiName', threadInfo }); return { merged: ET`${thread}: ${notificationTextWithoutSpoilers}`, body: notificationTextWithoutSpoilers, title: threadInfo.uiName, }; } else { const creator = ET.user({ userInfo: messageInfo.creator }); const thread = ET.thread({ display: 'shortName', threadInfo }); return { merged: ET`${creator} to ${thread}: ${notificationTextWithoutSpoilers}`, body: notificationTextWithoutSpoilers, title: threadInfo.uiName, prefix: ET`${creator}:`, }; } }, generatesNotifs: async () => pushTypes.NOTIF, includedInRepliesCount: true, }); diff --git a/lib/types/messages/images.js b/lib/types/messages/images.js index 360094dcc..c0f441e24 100644 --- a/lib/types/messages/images.js +++ b/lib/types/messages/images.js @@ -1,28 +1,33 @@ // @flow import type { Image } from '../media-types.js'; import type { RelativeUserInfo } from '../user-types.js'; -export type ImagesMessageData = { +type ImagesSharedBase = { +type: 14, +localID?: string, // for optimistic creations. included by new clients +threadID: string, +creatorID: string, +time: number, +media: $ReadOnlyArray, }; +export type ImagesMessageData = { + ...ImagesSharedBase, + +sidebarCreation?: boolean, +}; + export type RawImagesMessageInfo = { - ...ImagesMessageData, + ...ImagesSharedBase, +id?: string, // null if local copy without ID yet }; export type ImagesMessageInfo = { +type: 14, +id?: string, // null if local copy without ID yet +localID?: string, // for optimistic creations +threadID: string, +creator: RelativeUserInfo, +time: number, // millisecond timestamp +media: $ReadOnlyArray, }; diff --git a/lib/types/messages/media.js b/lib/types/messages/media.js index 4b649ddcd..4a8ed6337 100644 --- a/lib/types/messages/media.js +++ b/lib/types/messages/media.js @@ -1,73 +1,78 @@ // @flow import type { Media } from '../media-types.js'; import type { RelativeUserInfo } from '../user-types.js'; -export type MediaMessageData = { +type MediaSharedBase = { +type: 15, +localID?: string, // for optimistic creations. included by new clients +threadID: string, +creatorID: string, +time: number, +media: $ReadOnlyArray, }; +export type MediaMessageData = { + ...MediaSharedBase, + +sidebarCreation?: boolean, +}; + export type RawMediaMessageInfo = { - ...MediaMessageData, + ...MediaSharedBase, +id?: string, // null if local copy without ID yet }; export type MediaMessageInfo = { +type: 15, +id?: string, // null if local copy without ID yet +localID?: string, // for optimistic creations +threadID: string, +creator: RelativeUserInfo, +time: number, // millisecond timestamp +media: $ReadOnlyArray, }; export type MediaMessageServerDBContent = | { +type: 'photo', +uploadID: string, } | { +type: 'video', +uploadID: string, +thumbnailUploadID: string, }; function getUploadIDsFromMediaMessageServerDBContents( mediaMessageContents: $ReadOnlyArray, ): $ReadOnlyArray { const uploadIDs: string[] = []; for (const mediaContent of mediaMessageContents) { uploadIDs.push(mediaContent.uploadID); if (mediaContent.type === 'video') { uploadIDs.push(mediaContent.thumbnailUploadID); } } return uploadIDs; } function getMediaMessageServerDBContentsFromMedia( media: $ReadOnlyArray, ): $ReadOnlyArray { return media.map(m => { if (m.type === 'photo') { return { type: 'photo', uploadID: m.id }; } else { return { type: 'video', uploadID: m.id, thumbnailUploadID: m.thumbnailID, }; } }); } export { getUploadIDsFromMediaMessageServerDBContents, getMediaMessageServerDBContentsFromMedia, }; diff --git a/lib/types/messages/text.js b/lib/types/messages/text.js index 932712c4f..fcf7ef08e 100644 --- a/lib/types/messages/text.js +++ b/lib/types/messages/text.js @@ -1,27 +1,32 @@ // @flow import type { RelativeUserInfo } from '../user-types.js'; -export type TextMessageData = { +type TextSharedBase = { +type: 0, +localID?: string, // for optimistic creations. included by new clients +threadID: string, +creatorID: string, +time: number, +text: string, }; +export type TextMessageData = { + ...TextSharedBase, + +sidebarCreation?: boolean, +}; + export type RawTextMessageInfo = { - ...TextMessageData, + ...TextSharedBase, +id?: string, // null if local copy without ID yet }; export type TextMessageInfo = { +type: 0, +id?: string, // null if local copy without ID yet +localID?: string, // for optimistic creations +threadID: string, +creator: RelativeUserInfo, +time: number, // millisecond timestamp +text: string, };