Signal-Desktop/ts/types/Attachment.ts

522 lines
11 KiB
TypeScript
Raw Normal View History

// Copyright 2018-2021 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
2018-04-09 23:29:38 +00:00
import is from '@sindresorhus/is';
2018-04-25 17:27:28 +00:00
import moment from 'moment';
import { isNumber, padStart } from 'lodash';
2018-04-09 23:29:38 +00:00
2018-05-07 15:19:58 +00:00
import * as MIME from './MIME';
2018-05-07 18:15:06 +00:00
import { SignalService } from '../protobuf';
2019-01-14 21:49:58 +00:00
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../util/GoogleChrome';
2021-04-27 22:11:59 +00:00
import { LocalizerType, ThemeType } from './Util';
2019-01-14 21:49:58 +00:00
const MAX_WIDTH = 300;
const MAX_HEIGHT = MAX_WIDTH * 1.5;
const MIN_WIDTH = 200;
const MIN_HEIGHT = 50;
// Used for display
export type AttachmentType = {
error?: boolean;
2020-05-27 21:37:06 +00:00
blurHash?: string;
2019-01-14 21:49:58 +00:00
caption?: string;
contentType: MIME.MIMEType;
fileName?: string;
2019-01-14 21:49:58 +00:00
/** Not included in protobuf, needs to be pulled from flags */
isVoiceMessage?: boolean;
/** For messages not already on disk, this will be a data url */
url?: string;
2019-01-14 21:49:58 +00:00
size?: number;
2021-06-24 19:05:27 +00:00
fileSize?: string;
2019-01-14 21:49:58 +00:00
pending?: boolean;
width?: number;
height?: number;
path?: string;
2019-01-14 21:49:58 +00:00
screenshot?: {
height: number;
width: number;
url: string;
contentType: MIME.MIMEType;
path: string;
2019-01-14 21:49:58 +00:00
};
2021-06-25 16:08:16 +00:00
screenshotPath?: string;
flags?: number;
thumbnail?: ThumbnailType;
isCorrupted?: boolean;
downloadJobId?: string;
cdnNumber?: number;
cdnId?: string;
cdnKey?: string;
};
2021-06-25 16:08:16 +00:00
type BaseAttachmentDraftType = {
blurHash?: string;
contentType: MIME.MIMEType;
fileName: string;
screenshotContentType?: string;
screenshotSize?: number;
size: number;
};
export type InMemoryAttachmentDraftType = {
data?: ArrayBuffer;
screenshotData?: ArrayBuffer;
} & BaseAttachmentDraftType;
export type OnDiskAttachmentDraftType = {
path?: string;
screenshotPath?: string;
} & BaseAttachmentDraftType;
export type AttachmentDraftType = {
url: string;
} & BaseAttachmentDraftType;
export type ThumbnailType = {
height: number;
width: number;
url: string;
contentType: MIME.MIMEType;
path: string;
// Only used when quote needed to make an in-memory thumbnail
objectUrl?: string;
};
2019-01-14 21:49:58 +00:00
// UI-focused functions
export function getExtensionForDisplay({
fileName,
contentType,
}: {
fileName?: string;
2019-01-14 21:49:58 +00:00
contentType: MIME.MIMEType;
}): string | undefined {
if (fileName && fileName.indexOf('.') >= 0) {
const lastPeriod = fileName.lastIndexOf('.');
const extension = fileName.slice(lastPeriod + 1);
if (extension.length) {
return extension;
}
}
if (!contentType) {
return undefined;
2019-01-14 21:49:58 +00:00
}
const slash = contentType.indexOf('/');
if (slash >= 0) {
return contentType.slice(slash + 1);
}
return undefined;
2019-01-14 21:49:58 +00:00
}
export function isAudio(
attachments?: Array<AttachmentType>
): boolean | undefined {
2019-01-14 21:49:58 +00:00
return (
attachments &&
attachments[0] &&
attachments[0].contentType &&
!attachments[0].isCorrupted &&
2019-01-14 21:49:58 +00:00
MIME.isAudio(attachments[0].contentType)
);
}
export function canDisplayImage(
attachments?: Array<AttachmentType>
): boolean | 0 | undefined {
2019-01-14 21:49:58 +00:00
const { height, width } =
attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 };
return (
height &&
height > 0 &&
height <= 4096 &&
width &&
width > 0 &&
width <= 4096
);
}
export function getThumbnailUrl(
attachment: AttachmentType
): string | undefined {
2019-01-14 21:49:58 +00:00
if (attachment.thumbnail) {
return attachment.thumbnail.url;
}
return getUrl(attachment);
}
export function getUrl(attachment: AttachmentType): string | undefined {
2019-01-14 21:49:58 +00:00
if (attachment.screenshot) {
return attachment.screenshot.url;
}
return attachment.url;
}
export function isImage(
attachments?: Array<AttachmentType>
): boolean | undefined {
2019-01-14 21:49:58 +00:00
return (
attachments &&
attachments[0] &&
attachments[0].contentType &&
isImageTypeSupported(attachments[0].contentType)
);
}
export function isImageAttachment(
attachment?: AttachmentType
): attachment is AttachmentType {
return Boolean(
2019-01-14 21:49:58 +00:00
attachment &&
attachment.contentType &&
isImageTypeSupported(attachment.contentType)
2019-01-14 21:49:58 +00:00
);
}
export function hasImage(
attachments?: Array<AttachmentType>
): string | boolean | undefined {
2019-01-14 21:49:58 +00:00
return (
attachments &&
attachments[0] &&
2020-05-27 21:37:06 +00:00
(attachments[0].url || attachments[0].pending || attachments[0].blurHash)
2019-01-14 21:49:58 +00:00
);
}
2021-04-27 22:11:59 +00:00
export function isVideo(attachments?: Array<AttachmentType>): boolean {
if (!attachments || attachments.length === 0) {
return false;
}
return isVideoAttachment(attachments[0]);
2019-01-14 21:49:58 +00:00
}
2021-04-27 22:11:59 +00:00
export function isVideoAttachment(attachment?: AttachmentType): boolean {
if (!attachment || !attachment.contentType) {
return false;
}
return isVideoTypeSupported(attachment.contentType);
}
export function isGIF(attachments?: ReadonlyArray<AttachmentType>): boolean {
if (!attachments || attachments.length !== 1) {
return false;
}
const [attachment] = attachments;
const flag = SignalService.AttachmentPointer.Flags.GIF;
const hasFlag =
// eslint-disable-next-line no-bitwise
!is.undefined(attachment.flags) && (attachment.flags & flag) === flag;
return hasFlag && isVideoAttachment(attachment);
2019-01-14 21:49:58 +00:00
}
2021-01-29 22:58:28 +00:00
export function hasNotDownloaded(attachment?: AttachmentType): boolean {
return Boolean(attachment && !attachment.url);
2021-01-29 22:58:28 +00:00
}
export function hasVideoBlurHash(attachments?: Array<AttachmentType>): boolean {
const firstAttachment = attachments ? attachments[0] : null;
return Boolean(firstAttachment && firstAttachment.blurHash);
}
export function hasVideoScreenshot(
attachments?: Array<AttachmentType>
): string | null | undefined {
2019-01-14 21:49:58 +00:00
const firstAttachment = attachments ? attachments[0] : null;
return (
firstAttachment &&
firstAttachment.screenshot &&
firstAttachment.screenshot.url
);
}
type DimensionsType = {
height: number;
width: number;
};
export function getImageDimensions(
attachment: AttachmentType,
forcedWidth?: number
): DimensionsType {
2019-01-14 21:49:58 +00:00
const { height, width } = attachment;
if (!height || !width) {
return {
height: MIN_HEIGHT,
width: MIN_WIDTH,
};
}
const aspectRatio = height / width;
const targetWidth =
forcedWidth || Math.max(Math.min(MAX_WIDTH, width), MIN_WIDTH);
2019-01-14 21:49:58 +00:00
const candidateHeight = Math.round(targetWidth * aspectRatio);
return {
width: targetWidth,
height: Math.max(Math.min(MAX_HEIGHT, candidateHeight), MIN_HEIGHT),
};
}
export function areAllAttachmentsVisual(
attachments?: Array<AttachmentType>
): boolean {
if (!attachments) {
return false;
}
const max = attachments.length;
for (let i = 0; i < max; i += 1) {
const attachment = attachments[i];
if (!isImageAttachment(attachment) && !isVideoAttachment(attachment)) {
return false;
}
}
return true;
}
export function getGridDimensions(
attachments?: Array<AttachmentType>
): null | DimensionsType {
if (!attachments || !attachments.length) {
return null;
}
if (!isImage(attachments) && !isVideo(attachments)) {
return null;
}
if (attachments.length === 1) {
return getImageDimensions(attachments[0]);
}
if (attachments.length === 2) {
2021-06-24 21:00:11 +00:00
// A B
2019-01-14 21:49:58 +00:00
return {
height: 150,
width: 300,
};
}
2021-06-24 21:00:11 +00:00
if (attachments.length === 3) {
// A A B
// A A C
return {
height: 200,
width: 300,
};
}
2019-01-14 21:49:58 +00:00
if (attachments.length === 4) {
2021-06-24 21:00:11 +00:00
// A B
// C D
2019-01-14 21:49:58 +00:00
return {
height: 300,
width: 300,
};
}
2021-06-24 21:00:11 +00:00
// A A A B B B
// A A A B B B
// A A A B B B
// C C D D E E
// C C D D E E
2019-01-14 21:49:58 +00:00
return {
2021-06-24 21:00:11 +00:00
height: 250,
2019-01-14 21:49:58 +00:00
width: 300,
};
}
export function getAlt(
attachment: AttachmentType,
i18n: LocalizerType
): string {
2021-04-27 22:11:59 +00:00
if (isVideoAttachment(attachment)) {
return i18n('videoAttachmentAlt');
}
return i18n('imageAttachmentAlt');
2019-01-14 21:49:58 +00:00
}
// Migration-related attachment stuff
2018-04-09 23:28:54 +00:00
2018-04-26 20:47:05 +00:00
export type Attachment = {
2019-01-14 21:49:58 +00:00
fileName?: string;
2018-05-07 16:40:59 +00:00
flags?: SignalService.AttachmentPointer.Flags;
2018-05-07 15:19:58 +00:00
contentType?: MIME.MIMEType;
2018-04-09 23:28:54 +00:00
size?: number;
data: ArrayBuffer;
// // Omit unused / deprecated keys:
// schemaVersion?: number;
// id?: string;
// width?: number;
// height?: number;
// thumbnail?: ArrayBuffer;
// key?: ArrayBuffer;
// digest?: ArrayBuffer;
2018-04-26 20:47:05 +00:00
} & Partial<AttachmentSchemaVersion3>;
type AttachmentSchemaVersion3 = {
2018-04-26 20:47:05 +00:00
path: string;
};
2018-04-09 23:29:38 +00:00
export const isVisualMedia = (attachment: Attachment): boolean => {
const { contentType } = attachment;
if (is.undefined(contentType)) {
return false;
}
if (isVoiceMessage(attachment)) {
return false;
}
return MIME.isImage(contentType) || MIME.isVideo(contentType);
2018-04-09 23:29:38 +00:00
};
2018-04-25 17:27:28 +00:00
2018-05-07 19:24:46 +00:00
export const isFile = (attachment: Attachment): boolean => {
const { contentType } = attachment;
if (is.undefined(contentType)) {
return false;
}
if (isVisualMedia(attachment)) {
return false;
}
if (isVoiceMessage(attachment)) {
return false;
}
return true;
};
export const isVoiceMessage = (
attachment: Attachment | AttachmentType
): boolean => {
2018-05-07 16:40:59 +00:00
const flag = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE;
const hasFlag =
// eslint-disable-next-line no-bitwise
2018-05-07 16:40:59 +00:00
!is.undefined(attachment.flags) && (attachment.flags & flag) === flag;
if (hasFlag) {
return true;
}
const isLegacyAndroidVoiceMessage =
!is.undefined(attachment.contentType) &&
MIME.isAudio(attachment.contentType) &&
2019-01-14 21:49:58 +00:00
!attachment.fileName;
2018-05-07 16:40:59 +00:00
if (isLegacyAndroidVoiceMessage) {
return true;
}
return false;
};
export const save = async ({
2018-04-25 17:27:28 +00:00
attachment,
index,
readAttachmentData,
saveAttachmentToDisk,
2018-04-25 17:27:28 +00:00
timestamp,
}: {
attachment: Attachment;
index: number;
readAttachmentData: (relativePath: string) => Promise<ArrayBuffer>;
saveAttachmentToDisk: (options: {
data: ArrayBuffer;
name: string;
}) => Promise<{ name: string; fullPath: string }>;
2018-04-25 17:27:28 +00:00
timestamp?: number;
}): Promise<string | null> => {
if (!attachment.path && !attachment.data) {
throw new Error('Attachment had neither path nor data');
2018-04-30 15:01:57 +00:00
}
const data = attachment.path
? await readAttachmentData(attachment.path)
: attachment.data;
const name = getSuggestedFilename({ attachment, timestamp, index });
const result = await saveAttachmentToDisk({
data,
name,
});
if (!result) {
return null;
}
return result.fullPath;
2018-04-25 17:27:28 +00:00
};
export const getSuggestedFilename = ({
attachment,
timestamp,
index,
2018-04-25 17:27:28 +00:00
}: {
attachment: Attachment;
timestamp?: number | Date;
index?: number;
2018-04-25 17:27:28 +00:00
}): string => {
if (!isNumber(index) && attachment.fileName) {
2018-04-25 17:27:28 +00:00
return attachment.fileName;
}
const prefix = 'signal';
2018-04-25 17:27:28 +00:00
const suffix = timestamp
? moment(timestamp).format('-YYYY-MM-DD-HHmmss')
: '';
const fileType = getFileExtension(attachment);
const extension = fileType ? `.${fileType}` : '';
const indexSuffix = index ? `_${padStart(index.toString(), 3, '0')}` : '';
return `${prefix}${suffix}${indexSuffix}${extension}`;
2018-04-25 17:27:28 +00:00
};
2019-01-14 21:49:58 +00:00
export const getFileExtension = (
attachment: Attachment
): string | undefined => {
2018-04-25 17:27:28 +00:00
if (!attachment.contentType) {
return undefined;
2018-04-25 17:27:28 +00:00
}
switch (attachment.contentType) {
case 'video/quicktime':
return 'mov';
default:
return attachment.contentType.split('/')[1];
}
};
export const getUploadSizeLimitKb = (contentType: MIME.MIMEType): number => {
if (MIME.isGif(contentType)) {
return 25000;
}
if (isImageTypeSupported(contentType)) {
return 6000;
}
return 100000;
};
2021-04-27 22:11:59 +00:00
export const defaultBlurHash = (theme: ThemeType = ThemeType.light): string => {
if (theme === ThemeType.dark) {
return 'L05OQnoffQofoffQfQfQfQfQfQfQ';
}
return 'L1Q]+w-;fQ-;~qfQfQfQfQfQfQfQ';
};